Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1b39abe3bc | |||
| 29bff59707 | |||
| faa8f38fa8 | |||
| 1f6dbe96f6 | |||
| 98075da069 | |||
| 652bb8ef95 | |||
| 96d2b53798 | |||
| 25d621ab94 | |||
| fa3f19e7bf | |||
| 412ea937ff | |||
| b7f5c144a8 | |||
| 658128c892 | |||
| ff2f6029ce | |||
| 8017a04efe | |||
| ef350949fd | |||
| 7b1b3970b1 | |||
| e03f3c05b3 | |||
| 3e8e2c68b9 | |||
| 54e52182ab | |||
| c35872531f | |||
| 7d5c90a81e | |||
| 1f52b71477 | |||
| 9a7f7ef35c | |||
| a41128dae3 | |||
| 5c3094520d | |||
| 8db1d13c71 | |||
| 47c6cb88a4 | |||
| a1d4740785 | |||
| b3d685cc31 | |||
| 019f26a17c | |||
| 9970af5fe9 | |||
| f7e72ef62b | |||
| a445e29bca | |||
| ba69e29e8f | |||
| 45d826c941 | |||
| 583453f327 | |||
| 75be1b4ff9 | |||
| 2bbebeb925 | |||
| 4f660cc5f5 | |||
| 3c44c7416f | |||
| e7e50243d1 | |||
| b6a3ffb20f | |||
| 5a78684998 | |||
| ead761dfa2 | |||
| 330a7afdfc | |||
| ec5f50913a | |||
| f33e8c518f | |||
| aa4544accb | |||
| f6d8859dd2 | |||
| ce99319ea5 | |||
| 64e4414a5e | |||
| 32ffedd365 | |||
| 904ce226fb | |||
| 565b26e884 | |||
| 0b9fbb1800 | |||
| 2750a5c3e6 | |||
| cdbdf1ba4f | |||
| d58f62cb5e | |||
| f1c4605fba | |||
| deb55a74da | |||
| 30da629285 | |||
| 26b28001c5 | |||
| 64f8059f00 | |||
| 8363183943 | |||
| e19279fda5 | |||
| 591ffe2340 | |||
| fc4e8e5e7b | |||
| 36d2accb5b | |||
| 38de9765df | |||
| 6b02892c28 | |||
| c544da7426 | |||
| 71f0f53ddc | |||
| 03c517b066 | |||
| b05fcd7904 | |||
| 940861e2be | |||
| 559ce6a275 | |||
| 273e1fd2be | |||
| 5ddc18f8ed | |||
| 489a6e766b | |||
| 572f2cc167 | |||
| 5321c60058 | |||
| 00a86757fa | |||
| b06d624d43 | |||
| 89b1d5bb68 | |||
| bf389440dc | |||
| 2b9cc39d2b | |||
| afe3fd5ec0 | |||
| e29d5a1356 | |||
| 5f7b447d7a | |||
| 0e3f462bfb | |||
| 8feab57d59 | |||
| 2bda40d352 | |||
| 47398f03dd | |||
| 3f0f5dc303 | |||
| b5ac3ee288 | |||
| 51c99d26b4 | |||
| f77ce413be | |||
| 7a8159052e | |||
| 8ec6afb85a | |||
| bbf2d0e6ad | |||
| c073cee049 | |||
| e9f1148c0a | |||
| a420007e80 | |||
| 64a9bfcc22 | |||
| fd53eda5c6 | |||
| d6574b4a2e | |||
| 8eb75beb96 | |||
| 68920a12aa | |||
| a806e070a2 | |||
| a87c78ca20 | |||
| 48df638f5d | |||
| c601266f9c | |||
| 30d615f206 | |||
| 2db8d70c2f | |||
| 3efffe7688 | |||
| dc777f78b8 | |||
| 4cd00da319 | |||
| 3f6486db3e | |||
| 2d41fe837c | |||
| 34394d90c0 | |||
| fa29aea68e | |||
| 7928b31087 | |||
| e792350be6 | |||
| 5f0553dd22 | |||
| 8f6b77235e | |||
| 8ababc75d4 | |||
| 0a8f399655 | |||
| 19567e7fee | |||
| 3a137cb24c | |||
| 935af6904d | |||
| 4fed5ad21c | |||
| 9dc15687b5 | |||
| 38a0eca223 | |||
| 6836e0b511 | |||
| cab88b72b8 | |||
| 07421927ec | |||
| 828a2779a0 | |||
| 7392a5780c | |||
| 804270a797 | |||
| 7f5f286648 | |||
| 0a70a29e92 | |||
| dc2f2e8d3f | |||
| 6522a3ad1b | |||
| be65d4f33e | |||
| 0c15c75781 | |||
| 2bf51a033b | |||
| cfd8695aaa | |||
| e8a6a2e105 | |||
| 73a960af34 | |||
| bbb571fdf8 | |||
| c944be8215 | |||
| 5e903e04cf | |||
| 6884b0a421 | |||
| a1c7159304 | |||
| d65791027f | |||
| 5ffa0cba39 | |||
| f5be600383 | |||
| 9b2e26c270 | |||
| e25edea815 | |||
| 849000d5ac | |||
| cb06541fda | |||
| 70d1e733f6 | |||
| 0b3012071e | |||
| 42b7ed115f | |||
| 513a13f369 | |||
| f341d0787e | |||
| c8ee45b53c | |||
| b4e2dd4e06 | |||
| c663d8754b | |||
| 968a4e4818 | |||
| 833b95722e | |||
| 096e814929 |
+2
-2
@@ -550,8 +550,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/image_processing/ @home-assistant/core
|
||||
/homeassistant/components/image_upload/ @home-assistant/core
|
||||
/tests/components/image_upload/ @home-assistant/core
|
||||
/homeassistant/components/imap/ @engrbm87
|
||||
/tests/components/imap/ @engrbm87
|
||||
/homeassistant/components/imap/ @engrbm87 @jbouwh
|
||||
/tests/components/imap/ @engrbm87 @jbouwh
|
||||
/homeassistant/components/incomfort/ @zxdavb
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
|
||||
@@ -324,18 +324,29 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
all_identifiers = set(self.atv.all_identifiers)
|
||||
discovered_ip_address = str(self.atv.address)
|
||||
for entry in self._async_current_entries():
|
||||
if not all_identifiers.intersection(
|
||||
existing_identifiers = set(
|
||||
entry.data.get(CONF_IDENTIFIERS, [entry.unique_id])
|
||||
):
|
||||
)
|
||||
if not all_identifiers.intersection(existing_identifiers):
|
||||
continue
|
||||
if entry.data.get(CONF_ADDRESS) != discovered_ip_address:
|
||||
combined_identifiers = existing_identifiers | all_identifiers
|
||||
if entry.data.get(
|
||||
CONF_ADDRESS
|
||||
) != discovered_ip_address or combined_identifiers != set(
|
||||
entry.data.get(CONF_IDENTIFIERS, [])
|
||||
):
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, CONF_ADDRESS: discovered_ip_address},
|
||||
)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(entry.entry_id)
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_ADDRESS: discovered_ip_address,
|
||||
CONF_IDENTIFIERS: list(combined_identifiers),
|
||||
},
|
||||
)
|
||||
if entry.source != config_entries.SOURCE_IGNORE:
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(entry.entry_id)
|
||||
)
|
||||
if not allow_exist:
|
||||
raise DeviceAlreadyConfigured()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.collection import (
|
||||
StorageCollection,
|
||||
StorageCollectionWebsocket,
|
||||
)
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util import (
|
||||
dt as dt_util,
|
||||
@@ -369,7 +370,7 @@ class PipelineRun:
|
||||
def start(self) -> None:
|
||||
"""Emit run start event."""
|
||||
data = {
|
||||
"pipeline": self.pipeline.name,
|
||||
"pipeline": self.pipeline.id,
|
||||
"language": self.language,
|
||||
}
|
||||
if self.runner_data is not None:
|
||||
@@ -956,7 +957,8 @@ class PipelineRunDebug:
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_pipeline_store(hass: HomeAssistant) -> None:
|
||||
@singleton(DOMAIN)
|
||||
async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData:
|
||||
"""Set up the pipeline storage collection."""
|
||||
pipeline_store = PipelineStorageCollection(
|
||||
Store(hass, STORAGE_VERSION, STORAGE_KEY)
|
||||
@@ -969,4 +971,4 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> None:
|
||||
PIPELINE_FIELDS,
|
||||
PIPELINE_FIELDS,
|
||||
).async_setup(hass)
|
||||
hass.data[DOMAIN] = PipelineData({}, pipeline_store)
|
||||
return PipelineData({}, pipeline_store)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"assist_in_progress": {
|
||||
"name": "Assist in progress"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"pipeline": {
|
||||
"name": "Assist Pipeline",
|
||||
"name": "Assist pipeline",
|
||||
"state": {
|
||||
"preferred": "Preferred"
|
||||
}
|
||||
|
||||
@@ -39,7 +39,11 @@ async def async_setup_entry(
|
||||
class BAFFan(BAFEntity, FanEntity):
|
||||
"""BAF ceiling fan component."""
|
||||
|
||||
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.SET_SPEED
|
||||
| FanEntityFeature.DIRECTION
|
||||
| FanEntityFeature.PRESET_MODE
|
||||
)
|
||||
_attr_preset_modes = [PRESET_MODE_AUTO]
|
||||
_attr_speed_count = SPEED_COUNT
|
||||
|
||||
|
||||
@@ -89,7 +89,8 @@ class BondFan(BondEntity, FanEntity):
|
||||
features |= FanEntityFeature.SET_SPEED
|
||||
if self._device.supports_direction():
|
||||
features |= FanEntityFeature.DIRECTION
|
||||
|
||||
if self._device.has_action(Action.BREEZE_ON):
|
||||
features |= FanEntityFeature.PRESET_MODE
|
||||
return features
|
||||
|
||||
@property
|
||||
|
||||
@@ -20,14 +20,17 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
state_report as alexa_state_report,
|
||||
)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_get_assistant_settings,
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
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.helpers import entity_registry as er, start
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import utcnow
|
||||
@@ -51,6 +54,69 @@ CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}"
|
||||
SYNC_DELAY = 1
|
||||
|
||||
|
||||
SUPPORTED_DOMAINS = {
|
||||
"alarm_control_panel",
|
||||
"alert",
|
||||
"automation",
|
||||
"button",
|
||||
"camera",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
"group",
|
||||
"humidifier",
|
||||
"image_processing",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_number",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"number",
|
||||
"scene",
|
||||
"script",
|
||||
"switch",
|
||||
"timer",
|
||||
"vacuum",
|
||||
}
|
||||
|
||||
SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = {
|
||||
BinarySensorDeviceClass.DOOR,
|
||||
BinarySensorDeviceClass.GARAGE_DOOR,
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
BinarySensorDeviceClass.OPENING,
|
||||
BinarySensorDeviceClass.PRESENCE,
|
||||
BinarySensorDeviceClass.WINDOW,
|
||||
}
|
||||
|
||||
SUPPORTED_SENSOR_DEVICE_CLASSES = {
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
}
|
||||
|
||||
|
||||
def entity_supported(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
"""Return if the entity is supported.
|
||||
|
||||
This is called when migrating from legacy config format to avoid exposing
|
||||
all binary sensors and sensors.
|
||||
"""
|
||||
domain = split_entity_id(entity_id)[0]
|
||||
if domain in SUPPORTED_DOMAINS:
|
||||
return True
|
||||
|
||||
device_class = get_device_class(hass, entity_id)
|
||||
if (
|
||||
domain == "binary_sensor"
|
||||
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
|
||||
):
|
||||
return True
|
||||
|
||||
if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
"""Alexa Configuration."""
|
||||
|
||||
@@ -183,9 +249,13 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
|
||||
# Backwards compat
|
||||
if (default_expose := self._prefs.alexa_default_expose) is None:
|
||||
return not auxiliary_entity
|
||||
return not auxiliary_entity and entity_supported(self.hass, entity_id)
|
||||
|
||||
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose
|
||||
return (
|
||||
not auxiliary_entity
|
||||
and split_entity_id(entity_id)[0] in default_expose
|
||||
and entity_supported(self.hass, entity_id)
|
||||
)
|
||||
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
|
||||
@@ -7,12 +7,14 @@ from typing import Any
|
||||
from hass_nabucasa import Cloud, cloud_api
|
||||
from hass_nabucasa.google_report_state import ErrorResponse
|
||||
|
||||
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_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
@@ -22,6 +24,7 @@ from homeassistant.core import (
|
||||
split_entity_id,
|
||||
)
|
||||
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
|
||||
|
||||
from .const import (
|
||||
@@ -39,6 +42,73 @@ _LOGGER = logging.getLogger(__name__)
|
||||
CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}"
|
||||
|
||||
|
||||
SUPPORTED_DOMAINS = {
|
||||
"alarm_control_panel",
|
||||
"button",
|
||||
"camera",
|
||||
"climate",
|
||||
"cover",
|
||||
"fan",
|
||||
"group",
|
||||
"humidifier",
|
||||
"input_boolean",
|
||||
"input_button",
|
||||
"input_select",
|
||||
"light",
|
||||
"lock",
|
||||
"media_player",
|
||||
"scene",
|
||||
"script",
|
||||
"select",
|
||||
"switch",
|
||||
"vacuum",
|
||||
}
|
||||
|
||||
SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES = {
|
||||
BinarySensorDeviceClass.DOOR,
|
||||
BinarySensorDeviceClass.GARAGE_DOOR,
|
||||
BinarySensorDeviceClass.LOCK,
|
||||
BinarySensorDeviceClass.MOTION,
|
||||
BinarySensorDeviceClass.OPENING,
|
||||
BinarySensorDeviceClass.PRESENCE,
|
||||
BinarySensorDeviceClass.WINDOW,
|
||||
}
|
||||
|
||||
SUPPORTED_SENSOR_DEVICE_CLASSES = {
|
||||
SensorDeviceClass.AQI,
|
||||
SensorDeviceClass.CO,
|
||||
SensorDeviceClass.CO2,
|
||||
SensorDeviceClass.HUMIDITY,
|
||||
SensorDeviceClass.PM10,
|
||||
SensorDeviceClass.PM25,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS,
|
||||
}
|
||||
|
||||
|
||||
def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
"""Return if the entity is supported.
|
||||
|
||||
This is called when migrating from legacy config format to avoid exposing
|
||||
all binary sensors and sensors.
|
||||
"""
|
||||
domain = split_entity_id(entity_id)[0]
|
||||
if domain in SUPPORTED_DOMAINS:
|
||||
return True
|
||||
|
||||
device_class = get_device_class(hass, entity_id)
|
||||
if (
|
||||
domain == "binary_sensor"
|
||||
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
|
||||
):
|
||||
return True
|
||||
|
||||
if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CloudGoogleConfig(AbstractConfig):
|
||||
"""HA Cloud Configuration for Google Assistant."""
|
||||
|
||||
@@ -180,9 +250,13 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
# Backwards compat
|
||||
if default_expose is None:
|
||||
return not auxiliary_entity
|
||||
return not auxiliary_entity and _supported_legacy(self.hass, entity_id)
|
||||
|
||||
return not auxiliary_entity and split_entity_id(entity_id)[0] in default_expose
|
||||
return (
|
||||
not auxiliary_entity
|
||||
and split_entity_id(entity_id)[0] in default_expose
|
||||
and _supported_legacy(self.hass, entity_id)
|
||||
)
|
||||
|
||||
def _should_expose_entity_id(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.location import async_detect_location_info
|
||||
|
||||
from .alexa_config import entity_supported as entity_supported_by_alexa
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PREF_ALEXA_REPORT_STATE,
|
||||
@@ -73,6 +74,7 @@ async def async_setup(hass):
|
||||
websocket_api.async_register_command(hass, google_assistant_list)
|
||||
websocket_api.async_register_command(hass, google_assistant_update)
|
||||
|
||||
websocket_api.async_register_command(hass, alexa_get)
|
||||
websocket_api.async_register_command(hass, alexa_list)
|
||||
websocket_api.async_register_command(hass, alexa_sync)
|
||||
|
||||
@@ -198,12 +200,16 @@ class CloudLoginView(HomeAssistantView):
|
||||
cloud = hass.data[DOMAIN]
|
||||
await cloud.login(data["email"], data["password"])
|
||||
|
||||
if (cloud_pipeline_id := cloud_assist_pipeline(hass)) is None:
|
||||
# Make sure the pipeline store is loaded, needed because assist_pipeline
|
||||
# is an after dependency of cloud
|
||||
await assist_pipeline.async_setup_pipeline_store(hass)
|
||||
new_cloud_pipeline_id: str | None = None
|
||||
if (cloud_assist_pipeline(hass)) is None:
|
||||
if cloud_pipeline := await assist_pipeline.async_create_default_pipeline(
|
||||
hass, DOMAIN, DOMAIN
|
||||
):
|
||||
cloud_pipeline_id = cloud_pipeline.id
|
||||
return self.json({"success": True, "cloud_pipeline": cloud_pipeline_id})
|
||||
new_cloud_pipeline_id = cloud_pipeline.id
|
||||
return self.json({"success": True, "cloud_pipeline": new_cloud_pipeline_id})
|
||||
|
||||
|
||||
class CloudLogoutView(HomeAssistantView):
|
||||
@@ -664,6 +670,46 @@ async def google_assistant_update(
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@_require_cloud_login
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "cloud/alexa/entities/get",
|
||||
"entity_id": str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@_ws_handle_cloud_errors
|
||||
async def alexa_get(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
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
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_SUPPORTED,
|
||||
f"{entity_id} not supported by Alexa",
|
||||
)
|
||||
return
|
||||
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@_require_cloud_login
|
||||
@websocket_api.websocket_command({"type": "cloud/alexa/entities"})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"domain": "cloud",
|
||||
"name": "Home Assistant Cloud",
|
||||
"after_dependencies": ["google_assistant", "alexa"],
|
||||
"after_dependencies": ["assist_pipeline", "google_assistant", "alexa"],
|
||||
"codeowners": ["@home-assistant/cloud"],
|
||||
"dependencies": ["assist_pipeline", "homeassistant", "http", "webhook"],
|
||||
"dependencies": ["homeassistant", "http", "webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/cloud",
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.util import language as language_util
|
||||
|
||||
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
from .const import HOME_ASSISTANT_AGENT
|
||||
from .default_agent import DefaultAgent
|
||||
from .default_agent import DefaultAgent, async_setup as async_setup_default_agent
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
@@ -93,7 +93,9 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
@core.callback
|
||||
def _get_agent_manager(hass: HomeAssistant) -> AgentManager:
|
||||
"""Get the active agent."""
|
||||
return AgentManager(hass)
|
||||
manager = AgentManager(hass)
|
||||
manager.async_setup()
|
||||
return manager
|
||||
|
||||
|
||||
@core.callback
|
||||
@@ -389,7 +391,11 @@ class AgentManager:
|
||||
"""Initialize the conversation agents."""
|
||||
self.hass = hass
|
||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||
self._default_agent_init_lock = asyncio.Lock()
|
||||
self._builtin_agent_init_lock = asyncio.Lock()
|
||||
|
||||
def async_setup(self) -> None:
|
||||
"""Set up the conversation agents."""
|
||||
async_setup_default_agent(self.hass)
|
||||
|
||||
async def async_get_agent(
|
||||
self, agent_id: str | None = None
|
||||
@@ -402,7 +408,7 @@ class AgentManager:
|
||||
if self._builtin_agent is not None:
|
||||
return self._builtin_agent
|
||||
|
||||
async with self._default_agent_init_lock:
|
||||
async with self._builtin_agent_init_lock:
|
||||
if self._builtin_agent is not None:
|
||||
return self._builtin_agent
|
||||
|
||||
|
||||
@@ -73,6 +73,26 @@ def _get_language_variations(language: str) -> Iterable[str]:
|
||||
yield lang
|
||||
|
||||
|
||||
@core.callback
|
||||
def async_setup(hass: core.HomeAssistant) -> None:
|
||||
"""Set up entity registry listener for the default agent."""
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_id in entity_registry.entities:
|
||||
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"])
|
||||
|
||||
hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
async_handle_entity_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
|
||||
|
||||
class DefaultAgent(AbstractConversationAgent):
|
||||
"""Default agent for conversation agent."""
|
||||
|
||||
@@ -472,10 +492,10 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
return self._slot_lists
|
||||
|
||||
area_ids_with_entities: set[str] = set()
|
||||
all_entities = er.async_get(self.hass)
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entities = [
|
||||
entity
|
||||
for entity in all_entities.entities.values()
|
||||
for entity in entity_registry.entities.values()
|
||||
if async_should_expose(self.hass, DOMAIN, entity.entity_id)
|
||||
]
|
||||
devices = dr.async_get(self.hass)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.17-1"]
|
||||
"requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,10 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import IntegrationNotFound
|
||||
from homeassistant.requirements import async_get_integration_with_requirements
|
||||
from homeassistant.requirements import (
|
||||
RequirementsNotFound,
|
||||
async_get_integration_with_requirements,
|
||||
)
|
||||
|
||||
from .const import ( # noqa: F401
|
||||
CONF_IS_OFF,
|
||||
@@ -171,6 +174,10 @@ async def async_get_device_automation_platform(
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Integration '{domain}' not found"
|
||||
) from err
|
||||
except RequirementsNotFound as err:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Integration '{domain}' could not be loaded"
|
||||
) from err
|
||||
except ImportError as err:
|
||||
raise InvalidDeviceAutomationConfig(
|
||||
f"Integration '{domain}' does not support device automation "
|
||||
|
||||
@@ -288,39 +288,46 @@ async def async_setup_entry( # noqa: C901
|
||||
|
||||
voice_assistant_udp_server: VoiceAssistantUDPServer | None = None
|
||||
|
||||
def handle_pipeline_event(
|
||||
def _handle_pipeline_event(
|
||||
event_type: VoiceAssistantEventType, data: dict[str, str] | None
|
||||
) -> None:
|
||||
"""Handle a voice assistant pipeline event."""
|
||||
cli.send_voice_assistant_event(event_type, data)
|
||||
|
||||
async def handle_pipeline_start() -> int | None:
|
||||
def _handle_pipeline_finished() -> None:
|
||||
nonlocal voice_assistant_udp_server
|
||||
|
||||
entry_data.async_set_assist_pipeline_state(False)
|
||||
|
||||
if voice_assistant_udp_server is not None:
|
||||
voice_assistant_udp_server.close()
|
||||
voice_assistant_udp_server = None
|
||||
|
||||
async def _handle_pipeline_start() -> int | None:
|
||||
"""Start a voice assistant pipeline."""
|
||||
nonlocal voice_assistant_udp_server
|
||||
|
||||
if voice_assistant_udp_server is not None:
|
||||
return None
|
||||
|
||||
voice_assistant_udp_server = VoiceAssistantUDPServer(hass)
|
||||
voice_assistant_udp_server = VoiceAssistantUDPServer(
|
||||
hass, entry_data, _handle_pipeline_event, _handle_pipeline_finished
|
||||
)
|
||||
port = await voice_assistant_udp_server.start_server()
|
||||
|
||||
hass.async_create_background_task(
|
||||
voice_assistant_udp_server.run_pipeline(handle_pipeline_event),
|
||||
voice_assistant_udp_server.run_pipeline(),
|
||||
"esphome.voice_assistant_udp_server.run_pipeline",
|
||||
)
|
||||
entry_data.async_set_assist_pipeline_state(True)
|
||||
|
||||
return port
|
||||
|
||||
async def handle_pipeline_stop() -> None:
|
||||
async def _handle_pipeline_stop() -> None:
|
||||
"""Stop a voice assistant pipeline."""
|
||||
nonlocal voice_assistant_udp_server
|
||||
|
||||
entry_data.async_set_assist_pipeline_state(False)
|
||||
|
||||
if voice_assistant_udp_server is not None:
|
||||
voice_assistant_udp_server.stop()
|
||||
voice_assistant_udp_server = None
|
||||
|
||||
async def on_connect() -> None:
|
||||
"""Subscribe to states and list entities on successful API login."""
|
||||
@@ -369,8 +376,8 @@ async def async_setup_entry( # noqa: C901
|
||||
if device_info.voice_assistant_version:
|
||||
entry_data.disconnect_callbacks.append(
|
||||
await cli.subscribe_voice_assistant(
|
||||
handle_pipeline_start,
|
||||
handle_pipeline_stop,
|
||||
_handle_pipeline_start,
|
||||
_handle_pipeline_stop,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ async def async_setup_entry(
|
||||
entry_data = DomainData.get(hass).get_entry_data(entry)
|
||||
assert entry_data.device_info is not None
|
||||
if entry_data.device_info.voice_assistant_version:
|
||||
async_add_entities([EsphomeCallActiveBinarySensor(entry_data)])
|
||||
async_add_entities([EsphomeAssistInProgressBinarySensor(entry_data)])
|
||||
|
||||
|
||||
class EsphomeBinarySensor(
|
||||
@@ -68,12 +68,12 @@ class EsphomeBinarySensor(
|
||||
return super().available
|
||||
|
||||
|
||||
class EsphomeCallActiveBinarySensor(EsphomeAssistEntity, BinarySensorEntity):
|
||||
class EsphomeAssistInProgressBinarySensor(EsphomeAssistEntity, BinarySensorEntity):
|
||||
"""A binary sensor implementation for ESPHome for use with assist_pipeline."""
|
||||
|
||||
entity_description = BinarySensorEntityDescription(
|
||||
key="call_active",
|
||||
translation_key="call_active",
|
||||
key="assist_in_progress",
|
||||
translation_key="assist_in_progress",
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"call_active": {
|
||||
"name": "Call Active"
|
||||
"assist_in_progress": {
|
||||
"name": "[%key:component::assist_pipeline::entity::binary_sensor::assist_in_progress::name%]"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.update import (
|
||||
UpdateEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
@@ -33,35 +33,36 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up ESPHome update based on a config entry."""
|
||||
dashboard = async_get_dashboard(hass)
|
||||
|
||||
if dashboard is None:
|
||||
if (dashboard := async_get_dashboard(hass)) is None:
|
||||
return
|
||||
|
||||
entry_data = DomainData.get(hass).get_entry_data(entry)
|
||||
unsub = None
|
||||
unsubs: list[CALLBACK_TYPE] = []
|
||||
|
||||
async def setup_update_entity() -> None:
|
||||
@callback
|
||||
def _async_setup_update_entity() -> None:
|
||||
"""Set up the update entity."""
|
||||
nonlocal unsub
|
||||
|
||||
nonlocal unsubs
|
||||
assert dashboard is not None
|
||||
# Keep listening until device is available
|
||||
if not entry_data.available:
|
||||
if not entry_data.available or not dashboard.last_update_success:
|
||||
return
|
||||
|
||||
if unsub is not None:
|
||||
unsub() # type: ignore[unreachable]
|
||||
for unsub in unsubs:
|
||||
unsub()
|
||||
unsubs.clear()
|
||||
|
||||
assert dashboard is not None
|
||||
async_add_entities([ESPHomeUpdateEntity(entry_data, dashboard)])
|
||||
|
||||
if entry_data.available:
|
||||
await setup_update_entity()
|
||||
if entry_data.available and dashboard.last_update_success:
|
||||
_async_setup_update_entity()
|
||||
return
|
||||
|
||||
unsub = async_dispatcher_connect(
|
||||
hass, entry_data.signal_device_updated, setup_update_entity
|
||||
)
|
||||
unsubs = [
|
||||
async_dispatcher_connect(
|
||||
hass, entry_data.signal_device_updated, _async_setup_update_entity
|
||||
),
|
||||
dashboard.async_add_listener(_async_setup_update_entity),
|
||||
]
|
||||
|
||||
|
||||
class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
||||
@@ -88,7 +89,11 @@ class ESPHomeUpdateEntity(CoordinatorEntity[ESPHomeDashboard], UpdateEntity):
|
||||
|
||||
# If the device has deep sleep, we can't assume we can install updates
|
||||
# as the ESP will not be connectable (by design).
|
||||
if coordinator.supports_update and not self._device_info.has_deep_sleep:
|
||||
if (
|
||||
coordinator.last_update_success
|
||||
and coordinator.supports_update
|
||||
and not self._device_info.has_deep_sleep
|
||||
):
|
||||
self._attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
|
||||
@property
|
||||
|
||||
@@ -8,21 +8,26 @@ import socket
|
||||
from typing import cast
|
||||
|
||||
from aioesphomeapi import VoiceAssistantEventType
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components import stt
|
||||
from homeassistant.components import stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
PipelineEvent,
|
||||
PipelineEventType,
|
||||
async_pipeline_from_audio_stream,
|
||||
select as pipeline_select,
|
||||
)
|
||||
from homeassistant.components.media_player import async_process_play_media_url
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entry_data import RuntimeEntryData
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UDP_PORT = 0 # Set to 0 to let the OS pick a free random port
|
||||
UDP_MAX_PACKET_SIZE = 1024
|
||||
|
||||
_VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[
|
||||
VoiceAssistantEventType, PipelineEventType
|
||||
@@ -47,12 +52,26 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
|
||||
started = False
|
||||
queue: asyncio.Queue[bytes] | None = None
|
||||
transport: asyncio.DatagramTransport | None = None
|
||||
remote_addr: tuple[str, int] | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
entry_data: RuntimeEntryData,
|
||||
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
|
||||
handle_finished: Callable[[], None],
|
||||
) -> None:
|
||||
"""Initialize UDP receiver."""
|
||||
self.context = Context()
|
||||
self.hass = hass
|
||||
|
||||
assert entry_data.device_info is not None
|
||||
self.device_info = entry_data.device_info
|
||||
|
||||
self.queue = asyncio.Queue()
|
||||
self.handle_event = handle_event
|
||||
self.handle_finished = handle_finished
|
||||
self._tts_done = asyncio.Event()
|
||||
|
||||
async def start_server(self) -> int:
|
||||
"""Start accepting connections."""
|
||||
@@ -86,6 +105,10 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
|
||||
@callback
|
||||
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
|
||||
"""Handle incoming UDP packet."""
|
||||
if not self.started:
|
||||
return
|
||||
if self.remote_addr is None:
|
||||
self.remote_addr = addr
|
||||
if self.queue is not None:
|
||||
self.queue.put_nowait(data)
|
||||
|
||||
@@ -95,12 +118,18 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
|
||||
(Other than BlockingIOError or InterruptedError.)
|
||||
"""
|
||||
_LOGGER.error("ESPHome Voice Assistant UDP server error received: %s", exc)
|
||||
self.handle_finished()
|
||||
|
||||
@callback
|
||||
def stop(self) -> None:
|
||||
"""Stop the receiver."""
|
||||
if self.queue is not None:
|
||||
self.queue.put_nowait(b"")
|
||||
self.started = False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the receiver."""
|
||||
if self.queue is not None:
|
||||
self.queue = None
|
||||
if self.transport is not None:
|
||||
self.transport.close()
|
||||
@@ -113,54 +142,112 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol):
|
||||
while data := await self.queue.get():
|
||||
yield data
|
||||
|
||||
def _event_callback(self, event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
|
||||
try:
|
||||
event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type)
|
||||
except KeyError:
|
||||
_LOGGER.warning("Received unknown pipeline event type: %s", event.type)
|
||||
return
|
||||
|
||||
data_to_send = None
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["tts_input"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
assert event.data is not None
|
||||
path = event.data["tts_output"]["url"]
|
||||
url = async_process_play_media_url(self.hass, path)
|
||||
data_to_send = {"url": url}
|
||||
|
||||
if self.device_info.voice_assistant_version >= 2:
|
||||
media_id = event.data["tts_output"]["media_id"]
|
||||
self.hass.async_create_background_task(
|
||||
self._send_tts(media_id), "esphome_voice_assistant_tts"
|
||||
)
|
||||
else:
|
||||
self._tts_done.set()
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
"code": event.data["code"],
|
||||
"message": event.data["message"],
|
||||
}
|
||||
self.handle_finished()
|
||||
|
||||
self.handle_event(event_type, data_to_send)
|
||||
|
||||
async def run_pipeline(
|
||||
self,
|
||||
handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None],
|
||||
pipeline_timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Run the Voice Assistant pipeline."""
|
||||
try:
|
||||
tts_audio_output = (
|
||||
"raw" if self.device_info.voice_assistant_version >= 2 else "mp3"
|
||||
)
|
||||
async with async_timeout.timeout(pipeline_timeout):
|
||||
await async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self.context,
|
||||
event_callback=self._event_callback,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=self._iterate_packets(),
|
||||
pipeline_id=pipeline_select.get_chosen_pipeline(
|
||||
self.hass, DOMAIN, self.device_info.mac_address
|
||||
),
|
||||
tts_audio_output=tts_audio_output,
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_pipeline_event(event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
# Block until TTS is done sending
|
||||
await self._tts_done.wait()
|
||||
|
||||
try:
|
||||
event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type)
|
||||
except KeyError:
|
||||
_LOGGER.warning("Received unknown pipeline event type: %s", event.type)
|
||||
_LOGGER.debug("Pipeline finished")
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.warning("Pipeline timeout")
|
||||
finally:
|
||||
self.handle_finished()
|
||||
|
||||
async def _send_tts(self, media_id: str) -> None:
|
||||
"""Send TTS audio to device via UDP."""
|
||||
try:
|
||||
if self.transport is None:
|
||||
return
|
||||
|
||||
data_to_send = None
|
||||
if event_type == VoiceAssistantEventType.VOICE_ASSISTANT_STT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START:
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["tts_input"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END:
|
||||
assert event.data is not None
|
||||
path = event.data["tts_output"]["url"]
|
||||
url = async_process_play_media_url(self.hass, path)
|
||||
data_to_send = {"url": url}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_ERROR:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
"code": event.data["code"],
|
||||
"message": event.data["message"],
|
||||
}
|
||||
_extension, audio_bytes = await tts.async_get_media_source_audio(
|
||||
self.hass,
|
||||
media_id,
|
||||
)
|
||||
|
||||
handle_event(event_type, data_to_send)
|
||||
_LOGGER.debug("Sending %d bytes of audio", len(audio_bytes))
|
||||
|
||||
await async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self.context,
|
||||
event_callback=handle_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="",
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=self._iterate_packets(),
|
||||
)
|
||||
bytes_per_sample = stt.AudioBitRates.BITRATE_16 // 8
|
||||
sample_offset = 0
|
||||
samples_left = len(audio_bytes) // bytes_per_sample
|
||||
|
||||
while samples_left > 0:
|
||||
bytes_offset = sample_offset * bytes_per_sample
|
||||
chunk: bytes = audio_bytes[bytes_offset : bytes_offset + 1024]
|
||||
samples_in_chunk = len(chunk) // bytes_per_sample
|
||||
samples_left -= samples_in_chunk
|
||||
|
||||
self.transport.sendto(chunk, self.remote_addr)
|
||||
await asyncio.sleep(
|
||||
samples_in_chunk / stt.AudioSampleRates.SAMPLERATE_16000 * 0.99
|
||||
)
|
||||
|
||||
sample_offset += samples_in_chunk
|
||||
|
||||
finally:
|
||||
self._tts_done.set()
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/eufy",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["lakeside"],
|
||||
"requirements": ["lakeside==0.12"]
|
||||
"requirements": ["lakeside==0.13"]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,15 @@ SENSORS: tuple[ForecastSolarSensorEntityDescription, ...] = (
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
ForecastSolarSensorEntityDescription(
|
||||
key="energy_production_today_remaining",
|
||||
name="Estimated energy production - remaining today",
|
||||
state=lambda estimate: estimate.energy_production_today_remaining,
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
),
|
||||
ForecastSolarSensorEntityDescription(
|
||||
key="energy_production_tomorrow",
|
||||
name="Estimated energy production - tomorrow",
|
||||
|
||||
@@ -34,6 +34,7 @@ async def async_get_config_entry_diagnostics(
|
||||
},
|
||||
"data": {
|
||||
"energy_production_today": coordinator.data.energy_production_today,
|
||||
"energy_production_today_remaining": coordinator.data.energy_production_today_remaining,
|
||||
"energy_production_tomorrow": coordinator.data.energy_production_tomorrow,
|
||||
"energy_current_hour": coordinator.data.energy_current_hour,
|
||||
"power_production_now": coordinator.data.power_production_now,
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20230411.1"]
|
||||
"requirements": ["home-assistant-frontend==20230428.0"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from afsapi import AFSAPI, ConnectionError as FSConnectionError
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from .const import CONF_PIN, CONF_WEBFSAPI_URL, DOMAIN
|
||||
|
||||
@@ -28,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
try:
|
||||
await afsapi.get_power()
|
||||
except FSConnectionError as exception:
|
||||
raise PlatformNotReady from exception
|
||||
raise ConfigEntryNotReady from exception
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = afsapi
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -85,9 +85,12 @@ from .handler import ( # noqa: F401
|
||||
async_get_addon_discovery_info,
|
||||
async_get_addon_info,
|
||||
async_get_addon_store_info,
|
||||
async_get_yellow_settings,
|
||||
async_install_addon,
|
||||
async_reboot_host,
|
||||
async_restart_addon,
|
||||
async_set_addon_options,
|
||||
async_set_yellow_settings,
|
||||
async_start_addon,
|
||||
async_stop_addon,
|
||||
async_uninstall_addon,
|
||||
|
||||
@@ -262,6 +262,37 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b
|
||||
return await hassio.send_command(command, timeout=None)
|
||||
|
||||
|
||||
@api_data
|
||||
async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]:
|
||||
"""Return settings specific to Home Assistant Yellow."""
|
||||
hassio: HassIO = hass.data[DOMAIN]
|
||||
return await hassio.send_command("/os/boards/yellow", method="get")
|
||||
|
||||
|
||||
@api_data
|
||||
async def async_set_yellow_settings(
|
||||
hass: HomeAssistant, settings: dict[str, bool]
|
||||
) -> dict:
|
||||
"""Set settings specific to Home Assistant Yellow.
|
||||
|
||||
Returns an empty dict.
|
||||
"""
|
||||
hassio: HassIO = hass.data[DOMAIN]
|
||||
return await hassio.send_command(
|
||||
"/os/boards/yellow", method="post", payload=settings
|
||||
)
|
||||
|
||||
|
||||
@api_data
|
||||
async def async_reboot_host(hass: HomeAssistant) -> dict:
|
||||
"""Reboot the host.
|
||||
|
||||
Returns an empty dict.
|
||||
"""
|
||||
hassio: HassIO = hass.data[DOMAIN]
|
||||
return await hassio.send_command("/host/reboot", method="post", timeout=60)
|
||||
|
||||
|
||||
class HassIO:
|
||||
"""Small API wrapper for Hass.io."""
|
||||
|
||||
|
||||
@@ -156,6 +156,21 @@ class ExposedEntities:
|
||||
|
||||
return result
|
||||
|
||||
@callback
|
||||
def async_get_entity_settings(self, entity_id: str) -> dict[str, Mapping[str, Any]]:
|
||||
"""Get assistant expose settings for an entity."""
|
||||
entity_registry = er.async_get(self._hass)
|
||||
result: dict[str, Mapping[str, Any]] = {}
|
||||
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
raise HomeAssistantError("Unknown entity")
|
||||
|
||||
for assistant in KNOWN_ASSISTANTS:
|
||||
if options := registry_entry.options.get(assistant):
|
||||
result[assistant] = options
|
||||
|
||||
return result
|
||||
|
||||
@callback
|
||||
def async_should_expose(self, assistant: str, entity_id: str) -> bool:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
@@ -348,6 +363,27 @@ def async_get_assistant_settings(
|
||||
return exposed_entities.async_get_assistant_settings(assistant)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_entity_settings(
|
||||
hass: HomeAssistant, entity_id: str
|
||||
) -> dict[str, Mapping[str, Any]]:
|
||||
"""Get assistant expose settings for an entity."""
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
return exposed_entities.async_get_entity_settings(entity_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_expose_entity(
|
||||
hass: HomeAssistant,
|
||||
assistant: str,
|
||||
entity_id: str,
|
||||
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)
|
||||
|
||||
|
||||
@callback
|
||||
def async_should_expose(hass: HomeAssistant, assistant: str, entity_id: str) -> bool:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
"""Config flow for the Home Assistant Yellow integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.hassio import (
|
||||
HassioAPIError,
|
||||
async_get_yellow_settings,
|
||||
async_reboot_host,
|
||||
async_set_yellow_settings,
|
||||
)
|
||||
from homeassistant.components.homeassistant_hardware import silabs_multiprotocol_addon
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigFlow
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers import selector
|
||||
|
||||
from .const import DOMAIN, ZHA_HW_DISCOVERY_DATA
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_HW_SETTINGS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("disk_led"): selector.BooleanSelector(),
|
||||
vol.Required("heartbeat_led"): selector.BooleanSelector(),
|
||||
vol.Required("power_led"): selector.BooleanSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Home Assistant Yellow."""
|
||||
@@ -35,6 +57,82 @@ class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler):
|
||||
"""Handle an option flow for Home Assistant Yellow."""
|
||||
|
||||
_hw_settings: dict[str, bool] | None = None
|
||||
|
||||
async def async_step_on_supervisor(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle logic when on Supervisor host."""
|
||||
return self.async_show_menu(
|
||||
step_id="main_menu",
|
||||
menu_options=[
|
||||
"hardware_settings",
|
||||
"multipan_settings",
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_hardware_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle hardware settings."""
|
||||
|
||||
if user_input is not None:
|
||||
if self._hw_settings == user_input:
|
||||
return self.async_create_entry(data={})
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
await async_set_yellow_settings(self.hass, user_input)
|
||||
except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err:
|
||||
_LOGGER.warning("Failed to write hardware settings", exc_info=err)
|
||||
return self.async_abort(reason="write_hw_settings_error")
|
||||
return await self.async_step_confirm_reboot()
|
||||
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
self._hw_settings: dict[str, bool] = await async_get_yellow_settings(
|
||||
self.hass
|
||||
)
|
||||
except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err:
|
||||
_LOGGER.warning("Failed to read hardware settings", exc_info=err)
|
||||
return self.async_abort(reason="read_hw_settings_error")
|
||||
|
||||
schema = self.add_suggested_values_to_schema(
|
||||
STEP_HW_SETTINGS_SCHEMA, self._hw_settings
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="hardware_settings", data_schema=schema)
|
||||
|
||||
async def async_step_confirm_reboot(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm reboot host."""
|
||||
return self.async_show_menu(
|
||||
step_id="reboot_menu",
|
||||
menu_options=[
|
||||
"reboot_now",
|
||||
"reboot_later",
|
||||
],
|
||||
)
|
||||
|
||||
async def async_step_reboot_now(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Reboot now."""
|
||||
await async_reboot_host(self.hass)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_reboot_later(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Reboot later."""
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_multipan_settings(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle multipan settings."""
|
||||
return await super().async_step_on_supervisor(user_input)
|
||||
|
||||
async def _async_serial_port_settings(
|
||||
self,
|
||||
) -> silabs_multiprotocol_addon.SerialPortSettings:
|
||||
|
||||
@@ -11,9 +11,31 @@
|
||||
"addon_installed_other_device": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]"
|
||||
},
|
||||
"hardware_settings": {
|
||||
"title": "Configure hardware settings",
|
||||
"data": {
|
||||
"disk_led": "Disk LED",
|
||||
"heartbeat_led": "Heartbeat LED",
|
||||
"power_led": "Power LED"
|
||||
}
|
||||
},
|
||||
"install_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]"
|
||||
},
|
||||
"main_menu": {
|
||||
"menu_options": {
|
||||
"hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]",
|
||||
"multipan_settings": "Configure IEEE 802.15.4 radio multiprotocol support"
|
||||
}
|
||||
},
|
||||
"reboot_menu": {
|
||||
"title": "Reboot required",
|
||||
"description": "The settings have changed, but the new settings will not take effect until the system is rebooted",
|
||||
"menu_options": {
|
||||
"reboot_later": "Reboot manually later",
|
||||
"reboot_now": "Reboot now"
|
||||
}
|
||||
},
|
||||
"show_revert_guide": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::show_revert_guide::description%]"
|
||||
@@ -31,6 +53,8 @@
|
||||
"addon_set_config_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_set_config_failed%]",
|
||||
"addon_start_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_start_failed%]",
|
||||
"not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]",
|
||||
"read_hw_settings_error": "Failed to read hardware settings",
|
||||
"write_hw_settings_error": "Failed to write hardware settings",
|
||||
"zha_migration_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::zha_migration_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
|
||||
@@ -177,7 +177,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]):
|
||||
"search": self.config_entry.data[CONF_SEARCH],
|
||||
"folder": self.config_entry.data[CONF_FOLDER],
|
||||
"date": message.date,
|
||||
"text": message.text,
|
||||
"text": message.text[:2048],
|
||||
"sender": message.sender,
|
||||
"subject": message.subject,
|
||||
"headers": message.headers,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "imap",
|
||||
"name": "IMAP",
|
||||
"codeowners": ["@engrbm87"],
|
||||
"codeowners": ["@engrbm87", "@jbouwh"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["repairs"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/imap",
|
||||
|
||||
@@ -38,11 +38,15 @@ def async_describe_events(
|
||||
device_type = data[ATTR_TYPE]
|
||||
leap_button_number = data[ATTR_LEAP_BUTTON_NUMBER]
|
||||
dr_device_id = data[ATTR_DEVICE_ID]
|
||||
lutron_data = get_lutron_data_by_dr_id(hass, dr_device_id)
|
||||
keypad = lutron_data.keypad_data.dr_device_id_to_keypad.get(dr_device_id)
|
||||
keypad_id = keypad["lutron_device_id"]
|
||||
rev_button_map: dict[int, str] | None = None
|
||||
keypad_button_names_to_leap: dict[int, dict[str, int]] = {}
|
||||
keypad_id: int = -1
|
||||
|
||||
keypad_button_names_to_leap = lutron_data.keypad_data.button_names_to_leap
|
||||
if lutron_data := get_lutron_data_by_dr_id(hass, dr_device_id):
|
||||
keypad_data = lutron_data.keypad_data
|
||||
keypad = keypad_data.dr_device_id_to_keypad.get(dr_device_id)
|
||||
keypad_id = keypad["lutron_device_id"]
|
||||
keypad_button_names_to_leap = keypad_data.button_names_to_leap
|
||||
|
||||
if not (rev_button_map := LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP.get(device_type)):
|
||||
if fwd_button_map := keypad_button_names_to_leap.get(keypad_id):
|
||||
|
||||
@@ -195,6 +195,17 @@ async def async_remove_config_entry_device(
|
||||
if node is None:
|
||||
return True
|
||||
|
||||
if node.is_bridge_device:
|
||||
device_registry = dr.async_get(hass)
|
||||
devices = dr.async_entries_for_config_entry(
|
||||
device_registry, config_entry.entry_id
|
||||
)
|
||||
for device in devices:
|
||||
if device.via_device_id == device_entry.id:
|
||||
device_registry.async_update_device(
|
||||
device.id, remove_config_entry_id=config_entry.entry_id
|
||||
)
|
||||
|
||||
matter = get_matter(hass)
|
||||
await matter.matter_client.remove_node(node.node_id)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from chip.clusters import Objects as clusters
|
||||
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_POSITION,
|
||||
CoverDeviceClass,
|
||||
CoverEntity,
|
||||
CoverEntityDescription,
|
||||
CoverEntityFeature,
|
||||
@@ -25,6 +26,12 @@ from .models import MatterDiscoverySchema
|
||||
# The MASK used for extracting bits 0 to 1 of the byte.
|
||||
OPERATIONAL_STATUS_MASK = 0b11
|
||||
|
||||
# map Matter window cover types to HA device class
|
||||
TYPE_MAP = {
|
||||
clusters.WindowCovering.Enums.Type.kAwning: CoverDeviceClass.AWNING,
|
||||
clusters.WindowCovering.Enums.Type.kDrapery: CoverDeviceClass.CURTAIN,
|
||||
}
|
||||
|
||||
|
||||
class OperationalStatus(IntEnum):
|
||||
"""Currently ongoing operations enumeration for coverings, as defined in the Matter spec."""
|
||||
@@ -56,20 +63,6 @@ class MatterCover(MatterEntity, CoverEntity):
|
||||
| CoverEntityFeature.SET_POSITION
|
||||
)
|
||||
|
||||
@property
|
||||
def current_cover_position(self) -> int:
|
||||
"""Return the current position of cover."""
|
||||
if self._attr_current_cover_position:
|
||||
current_position = self._attr_current_cover_position
|
||||
else:
|
||||
current_position = self.get_matter_attribute_value(
|
||||
clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage
|
||||
)
|
||||
|
||||
assert current_position is not None
|
||||
|
||||
return current_position
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
"""Return true if cover is closed, else False."""
|
||||
@@ -91,7 +84,8 @@ class MatterCover(MatterEntity, CoverEntity):
|
||||
"""Set the cover to a specific position."""
|
||||
position = kwargs[ATTR_POSITION]
|
||||
await self.send_device_command(
|
||||
clusters.WindowCovering.Commands.GoToLiftValue(position)
|
||||
# value needs to be inverted and is sent in 100ths
|
||||
clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100)
|
||||
)
|
||||
|
||||
async def send_device_command(self, command: Any) -> None:
|
||||
@@ -129,15 +123,25 @@ class MatterCover(MatterEntity, CoverEntity):
|
||||
self._attr_is_opening = False
|
||||
self._attr_is_closing = False
|
||||
|
||||
self._attr_current_cover_position = self.get_matter_attribute_value(
|
||||
# current position is inverted in matter (100 is closed, 0 is open)
|
||||
current_cover_position = self.get_matter_attribute_value(
|
||||
clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage
|
||||
)
|
||||
self._attr_current_cover_position = 100 - current_cover_position
|
||||
|
||||
LOGGER.debug(
|
||||
"Current position: %s for %s",
|
||||
self._attr_current_cover_position,
|
||||
"Current position for %s - raw: %s - corrected: %s",
|
||||
self.entity_id,
|
||||
current_cover_position,
|
||||
self.current_cover_position,
|
||||
)
|
||||
|
||||
# map matter type to HA deviceclass
|
||||
device_type: clusters.WindowCovering.Enums.Type = (
|
||||
self.get_matter_attribute_value(clusters.WindowCovering.Attributes.Type)
|
||||
)
|
||||
self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING)
|
||||
|
||||
|
||||
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||
DISCOVERY_SCHEMAS = [
|
||||
@@ -149,5 +153,5 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage,
|
||||
clusters.WindowCovering.Attributes.OperationalStatus,
|
||||
),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -740,6 +740,9 @@ class MQTT:
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
publish_birth_message(birth_message), self.hass.loop
|
||||
)
|
||||
else:
|
||||
# Update subscribe cooldown period to a shorter time
|
||||
self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN)
|
||||
|
||||
async def _async_resubscribe(self) -> None:
|
||||
"""Resubscribe on reconnect."""
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml": {
|
||||
"title": "The Netxcloud YAML configuration has been deprecated",
|
||||
"description": "Configuring Netxcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
"title": "The Nextcloud YAML configuration has been deprecated",
|
||||
"description": "Configuring Nextcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"sqlalchemy==2.0.10",
|
||||
"sqlalchemy==2.0.11",
|
||||
"fnv-hash-fast==0.3.1",
|
||||
"psutil-home-assistant==0.0.1"
|
||||
]
|
||||
|
||||
@@ -34,6 +34,7 @@ from .queries import (
|
||||
find_event_types_to_purge,
|
||||
find_events_to_purge,
|
||||
find_latest_statistics_runs_run_id,
|
||||
find_legacy_detached_states_and_attributes_to_purge,
|
||||
find_legacy_event_state_and_attributes_and_data_ids_to_purge,
|
||||
find_legacy_row,
|
||||
find_short_term_statistics_to_purge,
|
||||
@@ -146,7 +147,28 @@ def _purge_legacy_format(
|
||||
_purge_unused_attributes_ids(instance, session, attributes_ids)
|
||||
_purge_event_ids(session, event_ids)
|
||||
_purge_unused_data_ids(instance, session, data_ids)
|
||||
return bool(event_ids or state_ids or attributes_ids or data_ids)
|
||||
|
||||
# The database may still have some rows that have an event_id but are not
|
||||
# linked to any event. These rows are not linked to any event because the
|
||||
# event was deleted. We need to purge these rows as well or we will never
|
||||
# switch to the new format which will prevent us from purging any events
|
||||
# that happened after the detached states.
|
||||
(
|
||||
detached_state_ids,
|
||||
detached_attributes_ids,
|
||||
) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge(
|
||||
session, purge_before
|
||||
)
|
||||
_purge_state_ids(instance, session, detached_state_ids)
|
||||
_purge_unused_attributes_ids(instance, session, detached_attributes_ids)
|
||||
return bool(
|
||||
event_ids
|
||||
or state_ids
|
||||
or attributes_ids
|
||||
or data_ids
|
||||
or detached_state_ids
|
||||
or detached_attributes_ids
|
||||
)
|
||||
|
||||
|
||||
def _purge_states_and_attributes_ids(
|
||||
@@ -412,6 +434,31 @@ def _select_short_term_statistics_to_purge(
|
||||
return [statistic.id for statistic in statistics]
|
||||
|
||||
|
||||
def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge(
|
||||
session: Session, purge_before: datetime
|
||||
) -> tuple[set[int], set[int]]:
|
||||
"""Return a list of state, and attribute ids to purge.
|
||||
|
||||
We do not link these anymore since state_change events
|
||||
do not exist in the events table anymore, however we
|
||||
still need to be able to purge them.
|
||||
"""
|
||||
states = session.execute(
|
||||
find_legacy_detached_states_and_attributes_to_purge(
|
||||
dt_util.utc_to_timestamp(purge_before)
|
||||
)
|
||||
).all()
|
||||
_LOGGER.debug("Selected %s state ids to remove", len(states))
|
||||
state_ids = set()
|
||||
attributes_ids = set()
|
||||
for state in states:
|
||||
if state_id := state.state_id:
|
||||
state_ids.add(state_id)
|
||||
if attributes_id := state.attributes_id:
|
||||
attributes_ids.add(attributes_id)
|
||||
return state_ids, attributes_ids
|
||||
|
||||
|
||||
def _select_legacy_event_state_and_attributes_and_data_ids_to_purge(
|
||||
session: Session, purge_before: datetime
|
||||
) -> tuple[set[int], set[int], set[int], set[int]]:
|
||||
@@ -433,12 +480,12 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge(
|
||||
data_ids = set()
|
||||
for event in events:
|
||||
event_ids.add(event.event_id)
|
||||
if event.state_id:
|
||||
state_ids.add(event.state_id)
|
||||
if event.attributes_id:
|
||||
attributes_ids.add(event.attributes_id)
|
||||
if event.data_id:
|
||||
data_ids.add(event.data_id)
|
||||
if state_id := event.state_id:
|
||||
state_ids.add(state_id)
|
||||
if attributes_id := event.attributes_id:
|
||||
attributes_ids.add(attributes_id)
|
||||
if data_id := event.data_id:
|
||||
data_ids.add(data_id)
|
||||
return event_ids, state_ids, attributes_ids, data_ids
|
||||
|
||||
|
||||
|
||||
@@ -678,6 +678,22 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge(
|
||||
)
|
||||
|
||||
|
||||
def find_legacy_detached_states_and_attributes_to_purge(
|
||||
purge_before: float,
|
||||
) -> StatementLambdaElement:
|
||||
"""Find states rows with event_id set but not linked event_id in Events."""
|
||||
return lambda_stmt(
|
||||
lambda: select(States.state_id, States.attributes_id)
|
||||
.outerjoin(Events, States.event_id == Events.event_id)
|
||||
.filter(States.event_id.isnot(None))
|
||||
.filter(
|
||||
(States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None)
|
||||
)
|
||||
.filter(Events.event_id.is_(None))
|
||||
.limit(SQLITE_MAX_BIND_VARS)
|
||||
)
|
||||
|
||||
|
||||
def find_legacy_row() -> StatementLambdaElement:
|
||||
"""Check if there are still states in the table with an event_id."""
|
||||
# https://github.com/sqlalchemy/sqlalchemy/issues/9189
|
||||
|
||||
@@ -6,7 +6,13 @@ from typing import Any
|
||||
|
||||
from roborock.api import RoborockApiClient
|
||||
from roborock.containers import UserData
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.exceptions import (
|
||||
RoborockAccountDoesNotExist,
|
||||
RoborockException,
|
||||
RoborockInvalidCode,
|
||||
RoborockInvalidEmail,
|
||||
RoborockUrlException,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
@@ -43,9 +49,15 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._client = RoborockApiClient(username)
|
||||
try:
|
||||
await self._client.request_code()
|
||||
except RoborockAccountDoesNotExist:
|
||||
errors["base"] = "invalid_email"
|
||||
except RoborockUrlException:
|
||||
errors["base"] = "unknown_url"
|
||||
except RoborockInvalidEmail:
|
||||
errors["base"] = "invalid_email_format"
|
||||
except RoborockException as ex:
|
||||
_LOGGER.exception(ex)
|
||||
errors["base"] = "invalid_email"
|
||||
errors["base"] = "unknown_roborock"
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.exception(ex)
|
||||
errors["base"] = "unknown"
|
||||
@@ -70,9 +82,11 @@ class RoborockFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.debug("Logging into Roborock account using email provided code")
|
||||
try:
|
||||
login_data = await self._client.code_login(code)
|
||||
except RoborockInvalidCode:
|
||||
errors["base"] = "invalid_code"
|
||||
except RoborockException as ex:
|
||||
_LOGGER.exception(ex)
|
||||
errors["base"] = "invalid_code"
|
||||
errors["base"] = "unknown_roborock"
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
_LOGGER.exception(ex)
|
||||
errors["base"] = "unknown"
|
||||
|
||||
@@ -13,7 +13,7 @@ from roborock.containers import (
|
||||
)
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.local_api import RoborockLocalClient
|
||||
from roborock.typing import RoborockDeviceProp
|
||||
from roborock.typing import DeviceProp
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -26,9 +26,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RoborockDataUpdateCoordinator(
|
||||
DataUpdateCoordinator[dict[str, RoborockDeviceProp]]
|
||||
):
|
||||
class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, DeviceProp]]):
|
||||
"""Class to manage fetching data from the API."""
|
||||
|
||||
def __init__(
|
||||
@@ -50,7 +48,7 @@ class RoborockDataUpdateCoordinator(
|
||||
device,
|
||||
networking,
|
||||
product_info[device.product_id],
|
||||
RoborockDeviceProp(),
|
||||
DeviceProp(),
|
||||
)
|
||||
local_devices_info[device.duid] = RoborockLocalDeviceInfo(
|
||||
device, networking
|
||||
@@ -71,7 +69,7 @@ class RoborockDataUpdateCoordinator(
|
||||
else:
|
||||
device_info.props = device_prop
|
||||
|
||||
async def _async_update_data(self) -> dict[str, RoborockDeviceProp]:
|
||||
async def _async_update_data(self) -> dict[str, DeviceProp]:
|
||||
"""Update data via library."""
|
||||
try:
|
||||
await asyncio.gather(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/roborock",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["roborock"],
|
||||
"requirements": ["python-roborock==0.6.5"]
|
||||
"requirements": ["python-roborock==0.8.3"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo
|
||||
from roborock.typing import RoborockDeviceProp
|
||||
from roborock.typing import DeviceProp
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -12,4 +12,4 @@ class RoborockHassDeviceInfo:
|
||||
device: HomeDataDevice
|
||||
network_info: NetworkInfo
|
||||
product: HomeDataProduct
|
||||
props: RoborockDeviceProp
|
||||
props: DeviceProp
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"error": {
|
||||
"invalid_code": "The code you entered was incorrect, please check it and try again.",
|
||||
"invalid_email": "There is no account associated with the email you entered, please try again.",
|
||||
"invalid_email_format": "There is an issue with the formatting of your email - please try again.",
|
||||
"unknown_roborock": "There was an unknown roborock exception - please check your logs.",
|
||||
"unknown_url": "There was an issue determining the correct url for your roborock account - please check your logs.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"abort": {
|
||||
|
||||
@@ -506,13 +506,23 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
If media_type is "playlist", media_id should be a Sonos
|
||||
Playlist name. Otherwise, media_id should be a URI.
|
||||
"""
|
||||
is_radio = False
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
is_radio = media_id.startswith("media-source://radio_browser/")
|
||||
media_type = MediaType.MUSIC
|
||||
media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, media.url)
|
||||
|
||||
if kwargs.get(ATTR_MEDIA_ANNOUNCE):
|
||||
volume = kwargs.get("extra", {}).get("volume")
|
||||
_LOGGER.debug("Playing %s using websocket audioclip", media_id)
|
||||
try:
|
||||
assert self.speaker.websocket
|
||||
response, _ = await self.speaker.websocket.play_clip(
|
||||
media_id,
|
||||
async_process_play_media_url(self.hass, media_id),
|
||||
volume=volume,
|
||||
)
|
||||
except SonosWebsocketError as exc:
|
||||
@@ -526,16 +536,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
|
||||
media_type = spotify.resolve_spotify_media_type(media_type)
|
||||
media_id = spotify.spotify_uri_from_media_browser_url(media_id)
|
||||
|
||||
is_radio = False
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
is_radio = media_id.startswith("media-source://radio_browser/")
|
||||
media_type = MediaType.MUSIC
|
||||
media = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = media.url
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(self._play_media, media_type, media_id, is_radio, **kwargs)
|
||||
)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sql",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["sqlalchemy==2.0.10"]
|
||||
"requirements": ["sqlalchemy==2.0.11"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ENTITY_ID
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
@@ -104,17 +105,39 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Unload a config entry."""
|
||||
# Unhide the wrapped entry if registered
|
||||
"""Unload a config entry.
|
||||
|
||||
This will unhide the wrapped entity and restore assistant expose settings.
|
||||
"""
|
||||
registry = er.async_get(hass)
|
||||
try:
|
||||
entity_id = er.async_validate_entity_id(registry, entry.options[CONF_ENTITY_ID])
|
||||
switch_entity_id = er.async_validate_entity_id(
|
||||
registry, entry.options[CONF_ENTITY_ID]
|
||||
)
|
||||
except vol.Invalid:
|
||||
# The source entity has been removed from the entity registry
|
||||
return
|
||||
|
||||
if not (entity_entry := registry.async_get(entity_id)):
|
||||
if not (switch_entity_entry := registry.async_get(switch_entity_id)):
|
||||
return
|
||||
|
||||
if entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION:
|
||||
registry.async_update_entity(entity_id, hidden_by=None)
|
||||
# Unhide the wrapped entity
|
||||
if switch_entity_entry.hidden_by == er.RegistryEntryHider.INTEGRATION:
|
||||
registry.async_update_entity(switch_entity_id, hidden_by=None)
|
||||
|
||||
switch_as_x_entries = er.async_entries_for_config_entry(registry, entry.entry_id)
|
||||
if not switch_as_x_entries:
|
||||
return
|
||||
|
||||
switch_as_x_entry = switch_as_x_entries[0]
|
||||
|
||||
# Restore assistant expose settings
|
||||
expose_settings = exposed_entities.async_get_entity_settings(
|
||||
hass, switch_as_x_entry.entity_id
|
||||
)
|
||||
for assistant, settings in expose_settings.items():
|
||||
if (should_expose := settings.get("should_expose")) is None:
|
||||
continue
|
||||
exposed_entities.async_expose_entity(
|
||||
hass, assistant, switch_entity_id, should_expose
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
@@ -99,14 +100,37 @@ class BaseEntity(Entity):
|
||||
{"entity_id": self._switch_entity_id},
|
||||
)
|
||||
|
||||
if not self._is_new_entity:
|
||||
if not self._is_new_entity or not (
|
||||
wrapped_switch := registry.async_get(self._switch_entity_id)
|
||||
):
|
||||
return
|
||||
|
||||
wrapped_switch = registry.async_get(self._switch_entity_id)
|
||||
if not wrapped_switch or wrapped_switch.name is None:
|
||||
return
|
||||
def copy_custom_name(wrapped_switch: er.RegistryEntry) -> None:
|
||||
"""Copy the name set by user from the wrapped entity."""
|
||||
if wrapped_switch.name is None:
|
||||
return
|
||||
registry.async_update_entity(self.entity_id, name=wrapped_switch.name)
|
||||
|
||||
registry.async_update_entity(self.entity_id, name=wrapped_switch.name)
|
||||
def copy_expose_settings() -> None:
|
||||
"""Copy assistant expose settings from the wrapped entity.
|
||||
|
||||
Also unexpose the wrapped entity if exposed.
|
||||
"""
|
||||
expose_settings = exposed_entities.async_get_entity_settings(
|
||||
self.hass, self._switch_entity_id
|
||||
)
|
||||
for assistant, settings in expose_settings.items():
|
||||
if (should_expose := settings.get("should_expose")) is None:
|
||||
continue
|
||||
exposed_entities.async_expose_entity(
|
||||
self.hass, assistant, self.entity_id, should_expose
|
||||
)
|
||||
exposed_entities.async_expose_entity(
|
||||
self.hass, assistant, self._switch_entity_id, False
|
||||
)
|
||||
|
||||
copy_custom_name(wrapped_switch)
|
||||
copy_expose_settings()
|
||||
|
||||
|
||||
class BaseToggleEntity(BaseEntity, ToggleEntity):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from ipaddress import ip_address
|
||||
from ipaddress import ip_address as ip
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
@@ -38,6 +38,7 @@ from homeassistant.data_entry_flow import FlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||
from homeassistant.util.network import is_ip_address as is_ip
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_TOKEN,
|
||||
@@ -99,14 +100,6 @@ def _ordered_shared_schema(
|
||||
}
|
||||
|
||||
|
||||
def _is_valid_ip(text: str) -> bool:
|
||||
try:
|
||||
ip_address(text)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def format_synology_mac(mac: str) -> str:
|
||||
"""Format a mac address to the format used by Synology DSM."""
|
||||
return mac.replace(":", "").replace("-", "").upper()
|
||||
@@ -284,16 +277,12 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
break
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
fqdn_with_ssl_verification = (
|
||||
existing_entry
|
||||
and not _is_valid_ip(existing_entry.data[CONF_HOST])
|
||||
and existing_entry.data[CONF_VERIFY_SSL]
|
||||
)
|
||||
|
||||
if (
|
||||
existing_entry
|
||||
and is_ip(existing_entry.data[CONF_HOST])
|
||||
and is_ip(host)
|
||||
and existing_entry.data[CONF_HOST] != host
|
||||
and not fqdn_with_ssl_verification
|
||||
and ip(existing_entry.data[CONF_HOST]).version == ip(host).version
|
||||
):
|
||||
_LOGGER.info(
|
||||
"Update host from '%s' to '%s' for NAS '%s' via discovery",
|
||||
|
||||
@@ -26,12 +26,6 @@ remove_torrent:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
name:
|
||||
name: Name
|
||||
description: Instance name as entered during entry config
|
||||
example: Transmission
|
||||
selector:
|
||||
text:
|
||||
id:
|
||||
name: ID
|
||||
description: ID of a torrent
|
||||
@@ -56,12 +50,6 @@ start_torrent:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
name:
|
||||
name: Name
|
||||
description: Instance name as entered during entry config
|
||||
example: Transmission
|
||||
selector:
|
||||
text:
|
||||
id:
|
||||
name: ID
|
||||
description: ID of a torrent
|
||||
@@ -79,12 +67,6 @@ stop_torrent:
|
||||
selector:
|
||||
config_entry:
|
||||
integration: transmission
|
||||
name:
|
||||
name: Name
|
||||
description: Instance name as entered during entry config
|
||||
example: Transmission
|
||||
selector:
|
||||
text:
|
||||
id:
|
||||
name: ID
|
||||
description: ID of a torrent
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
"codeowners": ["@raman325"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/vizio",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyvizio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyvizio==0.1.60"],
|
||||
"requirements": ["pyvizio==0.1.61"],
|
||||
"zeroconf": ["_viziocast._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -31,19 +31,19 @@ async def async_setup_entry(
|
||||
@callback
|
||||
def async_add_device(device: VoIPDevice) -> None:
|
||||
"""Add device."""
|
||||
async_add_entities([VoIPCallActive(device)])
|
||||
async_add_entities([VoIPCallInProgress(device)])
|
||||
|
||||
domain_data.devices.async_add_new_device_listener(async_add_device)
|
||||
|
||||
async_add_entities([VoIPCallActive(device) for device in domain_data.devices])
|
||||
async_add_entities([VoIPCallInProgress(device) for device in domain_data.devices])
|
||||
|
||||
|
||||
class VoIPCallActive(VoIPEntity, BinarySensorEntity):
|
||||
"""Entity to represent voip is allowed."""
|
||||
class VoIPCallInProgress(VoIPEntity, BinarySensorEntity):
|
||||
"""Entity to represent voip call is in progress."""
|
||||
|
||||
entity_description = BinarySensorEntityDescription(
|
||||
key="call_active",
|
||||
translation_key="call_active",
|
||||
key="call_in_progress",
|
||||
translation_key="call_in_progress",
|
||||
)
|
||||
_attr_is_on = False
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"call_active": {
|
||||
"name": "Call Active"
|
||||
"call_in_progress": {
|
||||
"name": "Call in progress"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"allow_call": {
|
||||
"name": "Allow Calls"
|
||||
"name": "Allow calls"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
|
||||
@@ -281,13 +281,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry):
|
||||
else:
|
||||
return self.async_create_entry(data=combined_input)
|
||||
|
||||
saved_options = self.options.copy()
|
||||
if saved_options[CONF_PROVINCE] is None:
|
||||
saved_options[CONF_PROVINCE] = NONE_SENTINEL
|
||||
schema: vol.Schema = await self.hass.async_add_executor_job(
|
||||
add_province_to_schema, DATA_SCHEMA_OPT, self.options
|
||||
)
|
||||
new_schema = self.add_suggested_values_to_schema(schema, user_input)
|
||||
|
||||
new_schema = self.add_suggested_values_to_schema(
|
||||
schema, user_input or self.options
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
|
||||
@@ -69,6 +69,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self._hassio_discovery = discovery_info
|
||||
self.context.update(
|
||||
{
|
||||
"title_placeholders": {"name": discovery_info.name},
|
||||
"configuration_url": f"homeassistant://hassio/addon/{discovery_info.slug}/info",
|
||||
}
|
||||
)
|
||||
return await self.async_step_hassio_confirm()
|
||||
|
||||
async def async_step_hassio_confirm(
|
||||
@@ -80,7 +86,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
if user_input is not None:
|
||||
uri = urlparse(self._hassio_discovery.config["uri"])
|
||||
if service := await WyomingService.create(uri.hostname, uri.port):
|
||||
if not any(asr for asr in service.info.asr if asr.installed):
|
||||
if not any(
|
||||
asr for asr in service.info.asr if asr.installed
|
||||
) and not any(tts for tts in service.info.tts if tts.installed):
|
||||
return self.async_abort(reason="no_services")
|
||||
|
||||
return self.async_create_entry(
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
from zigpy.backups import NetworkBackup
|
||||
from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH
|
||||
from zigpy.types import Channels
|
||||
from zigpy.util import pick_optimal_channel
|
||||
|
||||
from .core.const import (
|
||||
CONF_RADIO_TYPE,
|
||||
@@ -111,3 +113,22 @@ def async_get_radio_path(
|
||||
config_entry = _get_config_entry(hass)
|
||||
|
||||
return config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
|
||||
|
||||
async def async_change_channel(
|
||||
hass: HomeAssistant, new_channel: int | Literal["auto"]
|
||||
) -> None:
|
||||
"""Migrate the ZHA network to a new channel."""
|
||||
|
||||
zha_gateway: ZHAGateway = _get_gateway(hass)
|
||||
app = zha_gateway.application_controller
|
||||
|
||||
if new_channel == "auto":
|
||||
channel_energy = await app.energy_scan(
|
||||
channels=Channels.ALL_CHANNELS,
|
||||
duration_exp=4,
|
||||
count=1,
|
||||
)
|
||||
new_channel = pick_optimal_channel(channel_energy)
|
||||
|
||||
await app.move_network_to_channel(new_channel)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .core import discovery
|
||||
from .core.const import (
|
||||
CLUSTER_HANDLER_ACCELEROMETER,
|
||||
CLUSTER_HANDLER_BINARY_INPUT,
|
||||
CLUSTER_HANDLER_HUE_OCCUPANCY,
|
||||
CLUSTER_HANDLER_OCCUPANCY,
|
||||
CLUSTER_HANDLER_ON_OFF,
|
||||
CLUSTER_HANDLER_ZONE,
|
||||
@@ -130,6 +131,11 @@ class Occupancy(BinarySensor):
|
||||
_attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY
|
||||
|
||||
|
||||
@MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY)
|
||||
class HueOccupancy(Occupancy):
|
||||
"""ZHA Hue occupancy."""
|
||||
|
||||
|
||||
@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF)
|
||||
class Opening(BinarySensor):
|
||||
"""ZHA OnOff BinarySensor."""
|
||||
|
||||
@@ -32,7 +32,11 @@ from .core.const import (
|
||||
DOMAIN,
|
||||
RadioType,
|
||||
)
|
||||
from .radio_manager import HARDWARE_DISCOVERY_SCHEMA, ZhaRadioManager
|
||||
from .radio_manager import (
|
||||
HARDWARE_DISCOVERY_SCHEMA,
|
||||
RECOMMENDED_RADIOS,
|
||||
ZhaRadioManager,
|
||||
)
|
||||
|
||||
CONF_MANUAL_PATH = "Enter Manually"
|
||||
SUPPORTED_PORT_SETTINGS = (
|
||||
@@ -192,7 +196,7 @@ class BaseZhaFlow(FlowHandler):
|
||||
else ""
|
||||
)
|
||||
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
return await self.async_step_verify_radio()
|
||||
|
||||
# Pre-select the currently configured port
|
||||
default_port = vol.UNDEFINED
|
||||
@@ -252,7 +256,7 @@ class BaseZhaFlow(FlowHandler):
|
||||
self._radio_mgr.device_settings = user_input.copy()
|
||||
|
||||
if await self._radio_mgr.radio_type.controller.probe(user_input):
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
return await self.async_step_verify_radio()
|
||||
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
@@ -289,6 +293,26 @@ class BaseZhaFlow(FlowHandler):
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_verify_radio(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Add a warning step to dissuade the use of deprecated radios."""
|
||||
assert self._radio_mgr.radio_type is not None
|
||||
|
||||
# Skip this step if we are using a recommended radio
|
||||
if user_input is not None or self._radio_mgr.radio_type in RECOMMENDED_RADIOS:
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="verify_radio",
|
||||
description_placeholders={
|
||||
CONF_NAME: self._radio_mgr.radio_type.description,
|
||||
"docs_recommended_adapters_url": (
|
||||
"https://www.home-assistant.io/integrations/zha/#recommended-zigbee-radio-adapters-and-modules"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_choose_formation_strategy(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -516,7 +540,7 @@ class ZhaConfigFlowHandler(BaseZhaFlow, config_entries.ConfigFlow, domain=DOMAIN
|
||||
if self._radio_mgr.device_settings is None:
|
||||
return await self.async_step_manual_port_config()
|
||||
|
||||
return await self.async_step_choose_formation_strategy()
|
||||
return await self.async_step_verify_radio()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
|
||||
@@ -424,13 +424,13 @@ class ClusterHandler(LogMixin):
|
||||
else:
|
||||
raise TypeError(f"Unexpected zha_send_event {command!r} argument: {arg!r}")
|
||||
|
||||
self._endpoint.device.zha_send_event(
|
||||
self._endpoint.send_event(
|
||||
{
|
||||
ATTR_UNIQUE_ID: self.unique_id,
|
||||
ATTR_CLUSTER_ID: self.cluster.cluster_id,
|
||||
ATTR_COMMAND: command,
|
||||
# Maintain backwards compatibility with the old zigpy response format
|
||||
ATTR_ARGS: args, # type: ignore[dict-item]
|
||||
ATTR_ARGS: args,
|
||||
ATTR_PARAMS: params,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -347,7 +347,7 @@ class OnOffClientClusterHandler(ClientClusterHandler):
|
||||
class OnOffClusterHandler(ClusterHandler):
|
||||
"""Cluster handler for the OnOff Zigbee cluster."""
|
||||
|
||||
ON_OFF = 0
|
||||
ON_OFF = general.OnOff.attributes_by_name["on_off"].id
|
||||
REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),)
|
||||
ZCL_INIT_ATTRS = {
|
||||
"start_up_on_off": True,
|
||||
@@ -374,6 +374,15 @@ class OnOffClusterHandler(ClusterHandler):
|
||||
if self.cluster.endpoint.model == "TS011F":
|
||||
self.ZCL_INIT_ATTRS["child_lock"] = True
|
||||
|
||||
@classmethod
|
||||
def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool:
|
||||
"""Filter the cluster match for specific devices."""
|
||||
return not (
|
||||
cluster.endpoint.device.manufacturer == "Konke"
|
||||
and cluster.endpoint.device.model
|
||||
in ("3AFE280100510001", "3AFE170100510001")
|
||||
)
|
||||
|
||||
@property
|
||||
def on_off(self) -> bool | None:
|
||||
"""Return cached value of on/off attribute."""
|
||||
|
||||
@@ -78,6 +78,7 @@ CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT = "electrical_measurement"
|
||||
CLUSTER_HANDLER_EVENT_RELAY = "event_relay"
|
||||
CLUSTER_HANDLER_FAN = "fan"
|
||||
CLUSTER_HANDLER_HUMIDITY = "humidity"
|
||||
CLUSTER_HANDLER_HUE_OCCUPANCY = "philips_occupancy"
|
||||
CLUSTER_HANDLER_SOIL_MOISTURE = "soil_moisture"
|
||||
CLUSTER_HANDLER_LEAF_WETNESS = "leaf_wetness"
|
||||
CLUSTER_HANDLER_IAS_ACE = "ias_ace"
|
||||
@@ -151,7 +152,9 @@ CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours
|
||||
|
||||
CONF_ZHA_OPTIONS_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_DEFAULT_LIGHT_TRANSITION, default=0): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=0, max=2**16 / 10)
|
||||
),
|
||||
vol.Required(CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, default=False): cv.boolean,
|
||||
vol.Required(CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, default=True): cv.boolean,
|
||||
vol.Required(CONF_ALWAYS_PREFER_XY_COLOR_MODE, default=True): cv.boolean,
|
||||
|
||||
@@ -205,11 +205,13 @@ class Endpoint:
|
||||
|
||||
def send_event(self, signal: dict[str, Any]) -> None:
|
||||
"""Broadcast an event from this endpoint."""
|
||||
signal["endpoint"] = {
|
||||
"id": self.id,
|
||||
"unique_id": self.unique_id,
|
||||
}
|
||||
self.device.zha_send_event(signal)
|
||||
self.device.zha_send_event(
|
||||
{
|
||||
const.ATTR_UNIQUE_ID: self.unique_id,
|
||||
const.ATTR_ENDPOINT_ID: self.id,
|
||||
**signal,
|
||||
}
|
||||
)
|
||||
|
||||
def claim_cluster_handlers(self, cluster_handlers: list[ClusterHandler]) -> None:
|
||||
"""Claim cluster handlers."""
|
||||
|
||||
@@ -113,7 +113,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
"""Operations common to all light entities."""
|
||||
|
||||
_FORCE_ON = False
|
||||
_DEFAULT_MIN_TRANSITION_TIME = 0
|
||||
_DEFAULT_MIN_TRANSITION_TIME: float = 0
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""Initialize the light."""
|
||||
@@ -181,9 +181,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
"""Turn the entity on."""
|
||||
transition = kwargs.get(light.ATTR_TRANSITION)
|
||||
duration = (
|
||||
transition * 10
|
||||
if transition is not None
|
||||
else self._zha_config_transition * 10
|
||||
transition if transition is not None else self._zha_config_transition
|
||||
) or (
|
||||
# if 0 is passed in some devices still need the minimum default
|
||||
self._DEFAULT_MIN_TRANSITION_TIME
|
||||
@@ -210,7 +208,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
) and self._zha_config_enable_light_transitioning_flag
|
||||
transition_time = (
|
||||
(
|
||||
duration / 10 + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
|
||||
duration + DEFAULT_EXTRA_TRANSITION_DELAY_SHORT
|
||||
if (
|
||||
(brightness is not None or transition is not None)
|
||||
and brightness_supported(self._attr_supported_color_modes)
|
||||
@@ -297,7 +295,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
# After that, we set it to the desired color/temperature with no transition.
|
||||
result = await self._level_cluster_handler.move_to_level_with_on_off(
|
||||
level=DEFAULT_MIN_BRIGHTNESS,
|
||||
transition_time=self._DEFAULT_MIN_TRANSITION_TIME,
|
||||
transition_time=int(10 * self._DEFAULT_MIN_TRANSITION_TIME),
|
||||
)
|
||||
t_log["move_to_level_with_on_off"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -337,7 +335,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
):
|
||||
result = await self._level_cluster_handler.move_to_level_with_on_off(
|
||||
level=level,
|
||||
transition_time=duration,
|
||||
transition_time=int(10 * duration),
|
||||
)
|
||||
t_log["move_to_level_with_on_off"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -390,7 +388,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
# The light is has the correct color, so we can now transition
|
||||
# it to the correct brightness level.
|
||||
result = await self._level_cluster_handler.move_to_level(
|
||||
level=level, transition_time=duration
|
||||
level=level, transition_time=int(10 * duration)
|
||||
)
|
||||
t_log["move_to_level_if_color"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -465,7 +463,9 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
if transition is not None and supports_level:
|
||||
result = await self._level_cluster_handler.move_to_level_with_on_off(
|
||||
level=0,
|
||||
transition_time=(transition * 10 or self._DEFAULT_MIN_TRANSITION_TIME),
|
||||
transition_time=int(
|
||||
10 * (transition or self._DEFAULT_MIN_TRANSITION_TIME)
|
||||
),
|
||||
)
|
||||
else:
|
||||
result = await self._on_off_cluster_handler.off()
|
||||
@@ -511,7 +511,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
if temperature is not None:
|
||||
result = await self._color_cluster_handler.move_to_color_temp(
|
||||
color_temp_mireds=temperature,
|
||||
transition_time=transition_time,
|
||||
transition_time=int(10 * transition_time),
|
||||
)
|
||||
t_log["move_to_color_temp"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -529,14 +529,14 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
result = await self._color_cluster_handler.enhanced_move_to_hue_and_saturation(
|
||||
enhanced_hue=int(hs_color[0] * 65535 / 360),
|
||||
saturation=int(hs_color[1] * 2.54),
|
||||
transition_time=transition_time,
|
||||
transition_time=int(10 * transition_time),
|
||||
)
|
||||
t_log["enhanced_move_to_hue_and_saturation"] = result
|
||||
else:
|
||||
result = await self._color_cluster_handler.move_to_hue_and_saturation(
|
||||
hue=int(hs_color[0] * 254 / 360),
|
||||
saturation=int(hs_color[1] * 2.54),
|
||||
transition_time=transition_time,
|
||||
transition_time=int(10 * transition_time),
|
||||
)
|
||||
t_log["move_to_hue_and_saturation"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -551,7 +551,7 @@ class BaseLight(LogMixin, light.LightEntity):
|
||||
result = await self._color_cluster_handler.move_to_color(
|
||||
color_x=int(xy_color[0] * 65535),
|
||||
color_y=int(xy_color[1] * 65535),
|
||||
transition_time=transition_time,
|
||||
transition_time=int(10 * transition_time),
|
||||
)
|
||||
t_log["move_to_color"] = result
|
||||
if isinstance(result, Exception) or result[1] is not Status.SUCCESS:
|
||||
@@ -1091,7 +1091,9 @@ class MinTransitionLight(Light):
|
||||
"""Representation of a light which does not react to any "move to" calls with 0 as a transition."""
|
||||
|
||||
_attr_name: str = "Light"
|
||||
_DEFAULT_MIN_TRANSITION_TIME = 1
|
||||
|
||||
# Transitions are counted in 1/10th of a second increments, so this is the smallest
|
||||
_DEFAULT_MIN_TRANSITION_TIME = 0.1
|
||||
|
||||
|
||||
@GROUP_MATCH()
|
||||
@@ -1111,10 +1113,18 @@ class LightGroup(BaseLight, ZhaGroupEntity):
|
||||
group = self.zha_device.gateway.get_group(self._group_id)
|
||||
|
||||
self._GROUP_SUPPORTS_EXECUTE_IF_OFF = True # pylint: disable=invalid-name
|
||||
# Check all group members to see if they support execute_if_off.
|
||||
# If at least one member has a color cluster and doesn't support it,
|
||||
# it's not used.
|
||||
|
||||
for member in group.members:
|
||||
# Ensure we do not send group commands that violate the minimum transition
|
||||
# time of any members.
|
||||
if member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS:
|
||||
self._DEFAULT_MIN_TRANSITION_TIME = ( # pylint: disable=invalid-name
|
||||
MinTransitionLight._DEFAULT_MIN_TRANSITION_TIME
|
||||
)
|
||||
|
||||
# Check all group members to see if they support execute_if_off.
|
||||
# If at least one member has a color cluster and doesn't support it,
|
||||
# it's not used.
|
||||
for endpoint in member.device._endpoints.values():
|
||||
for cluster_handler in endpoint.all_cluster_handlers.values():
|
||||
if (
|
||||
@@ -1124,10 +1134,6 @@ class LightGroup(BaseLight, ZhaGroupEntity):
|
||||
self._GROUP_SUPPORTS_EXECUTE_IF_OFF = False
|
||||
break
|
||||
|
||||
self._DEFAULT_MIN_TRANSITION_TIME = any( # pylint: disable=invalid-name
|
||||
member.device.manufacturer in DEFAULT_MIN_TRANSITION_MANUFACTURERS
|
||||
for member in group.members
|
||||
)
|
||||
self._on_off_cluster_handler = group.endpoint[OnOff.cluster_id]
|
||||
self._level_cluster_handler = group.endpoint[LevelControl.cluster_id]
|
||||
self._color_cluster_handler = group.endpoint[Color.cluster_id]
|
||||
|
||||
@@ -20,10 +20,10 @@
|
||||
"zigpy_znp"
|
||||
],
|
||||
"requirements": [
|
||||
"bellows==0.35.1",
|
||||
"bellows==0.35.2",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.97",
|
||||
"zha-quirks==0.0.98",
|
||||
"zigpy-deconz==0.21.0",
|
||||
"zigpy==0.55.0",
|
||||
"zigpy-xbee==0.18.0",
|
||||
|
||||
@@ -40,6 +40,12 @@ AUTOPROBE_RADIOS = (
|
||||
RadioType.zigate,
|
||||
)
|
||||
|
||||
RECOMMENDED_RADIOS = (
|
||||
RadioType.ezsp,
|
||||
RadioType.znp,
|
||||
RadioType.deconz,
|
||||
)
|
||||
|
||||
CONNECT_DELAY_S = 1.0
|
||||
|
||||
MIGRATION_RETRIES = 100
|
||||
|
||||
@@ -20,9 +20,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .core import discovery
|
||||
from .core.const import (
|
||||
CLUSTER_HANDLER_HUE_OCCUPANCY,
|
||||
CLUSTER_HANDLER_IAS_WD,
|
||||
CLUSTER_HANDLER_INOVELLI,
|
||||
CLUSTER_HANDLER_OCCUPANCY,
|
||||
CLUSTER_HANDLER_ON_OFF,
|
||||
DATA_ZHA,
|
||||
SIGNAL_ADD_ENTITIES,
|
||||
@@ -367,7 +367,7 @@ class HueV1MotionSensitivities(types.enum8):
|
||||
|
||||
|
||||
@CONFIG_DIAGNOSTIC_MATCH(
|
||||
cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY,
|
||||
cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY,
|
||||
manufacturers={"Philips", "Signify Netherlands B.V."},
|
||||
models={"SML001"},
|
||||
)
|
||||
@@ -390,7 +390,7 @@ class HueV2MotionSensitivities(types.enum8):
|
||||
|
||||
|
||||
@CONFIG_DIAGNOSTIC_MATCH(
|
||||
cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY,
|
||||
cluster_handler_names=CLUSTER_HANDLER_HUE_OCCUPANCY,
|
||||
manufacturers={"Philips", "Signify Netherlands B.V."},
|
||||
models={"SML002", "SML003", "SML004"},
|
||||
)
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"flow_control": "data flow control"
|
||||
}
|
||||
},
|
||||
"verify_radio": {
|
||||
"title": "Radio is not recommended",
|
||||
"description": "The radio you are using ({name}) is not recommended and support for it may be removed in the future. Please see the Zigbee Home Automation integration's documentation for [a list of recommended adapters]({docs_recommended_adapters_url})."
|
||||
},
|
||||
"choose_formation_strategy": {
|
||||
"title": "Network Formation",
|
||||
"description": "Choose the network settings for your radio.",
|
||||
@@ -116,6 +120,10 @@
|
||||
"flow_control": "[%key:component::zha::config::step::manual_port_config::data::flow_control%]"
|
||||
}
|
||||
},
|
||||
"verify_radio": {
|
||||
"title": "[%key:component::zha::config::step::verify_radio::title%]",
|
||||
"description": "[%key:component::zha::config::step::verify_radio::description%]"
|
||||
},
|
||||
"choose_formation_strategy": {
|
||||
"title": "[%key:component::zha::config::step::choose_formation_strategy::title%]",
|
||||
"description": "[%key:component::zha::config::step::choose_formation_strategy::description%]",
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, NamedTuple, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast
|
||||
|
||||
import voluptuous as vol
|
||||
import zigpy.backups
|
||||
@@ -19,7 +19,11 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.service import async_register_admin_service
|
||||
|
||||
from .api import async_get_active_network_settings, async_get_radio_type
|
||||
from .api import (
|
||||
async_change_channel,
|
||||
async_get_active_network_settings,
|
||||
async_get_radio_type,
|
||||
)
|
||||
from .core.const import (
|
||||
ATTR_ARGS,
|
||||
ATTR_ATTRIBUTE,
|
||||
@@ -93,6 +97,7 @@ ATTR_DURATION = "duration"
|
||||
ATTR_GROUP = "group"
|
||||
ATTR_IEEE_ADDRESS = "ieee_address"
|
||||
ATTR_INSTALL_CODE = "install_code"
|
||||
ATTR_NEW_CHANNEL = "new_channel"
|
||||
ATTR_SOURCE_IEEE = "source_ieee"
|
||||
ATTR_TARGET_IEEE = "target_ieee"
|
||||
ATTR_QR_CODE = "qr_code"
|
||||
@@ -1204,6 +1209,23 @@ async def websocket_restore_network_backup(
|
||||
connection.send_result(msg[ID])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(TYPE): "zha/network/change_channel",
|
||||
vol.Required(ATTR_NEW_CHANNEL): vol.Any("auto", vol.Range(11, 26)),
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
async def websocket_change_channel(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Migrate the Zigbee network to a new channel."""
|
||||
new_channel = cast(Literal["auto"] | int, msg[ATTR_NEW_CHANNEL])
|
||||
await async_change_channel(hass, new_channel=new_channel)
|
||||
connection.send_result(msg[ID])
|
||||
|
||||
|
||||
@callback
|
||||
def async_load_api(hass: HomeAssistant) -> None:
|
||||
"""Set up the web socket API."""
|
||||
@@ -1527,6 +1549,7 @@ def async_load_api(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, websocket_list_network_backups)
|
||||
websocket_api.async_register_command(hass, websocket_create_network_backup)
|
||||
websocket_api.async_register_command(hass, websocket_restore_network_backup)
|
||||
websocket_api.async_register_command(hass, websocket_change_channel)
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 5
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
PATCH_VERSION: Final = "0b2"
|
||||
__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)
|
||||
|
||||
@@ -6059,7 +6059,7 @@
|
||||
},
|
||||
"vizio": {
|
||||
"name": "VIZIO SmartCast",
|
||||
"integration_type": "hub",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
|
||||
@@ -286,6 +286,28 @@ class AreaSelector(Selector[AreaSelectorConfig]):
|
||||
return [vol.Schema(str)(val) for val in data]
|
||||
|
||||
|
||||
class AssistPipelineSelectorConfig(TypedDict, total=False):
|
||||
"""Class to represent an assist pipeline selector config."""
|
||||
|
||||
|
||||
@SELECTORS.register("assist_pipeline")
|
||||
class AssistPipelineSelector(Selector[AssistPipelineSelectorConfig]):
|
||||
"""Selector for an assist pipeline."""
|
||||
|
||||
selector_type = "assist_pipeline"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({})
|
||||
|
||||
def __init__(self, config: AssistPipelineSelectorConfig) -> None:
|
||||
"""Instantiate a selector."""
|
||||
super().__init__(config)
|
||||
|
||||
def __call__(self, data: Any) -> str:
|
||||
"""Validate the passed selection."""
|
||||
pipeline: str = vol.Schema(str)(data)
|
||||
return pipeline
|
||||
|
||||
|
||||
class AttributeSelectorConfig(TypedDict, total=False):
|
||||
"""Class to represent an attribute selector config."""
|
||||
|
||||
@@ -659,6 +681,40 @@ class IconSelector(Selector[IconSelectorConfig]):
|
||||
return icon
|
||||
|
||||
|
||||
class LanguageSelectorConfig(TypedDict, total=False):
|
||||
"""Class to represent an language selector config."""
|
||||
|
||||
languages: list[str]
|
||||
native_name: bool
|
||||
no_sort: bool
|
||||
|
||||
|
||||
@SELECTORS.register("language")
|
||||
class LanguageSelector(Selector[LanguageSelectorConfig]):
|
||||
"""Selector for an language."""
|
||||
|
||||
selector_type = "language"
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Optional("languages"): [str],
|
||||
vol.Optional("native_name", default=False): cv.boolean,
|
||||
vol.Optional("no_sort", default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, config: LanguageSelectorConfig) -> None:
|
||||
"""Instantiate a selector."""
|
||||
super().__init__(config)
|
||||
|
||||
def __call__(self, data: Any) -> str:
|
||||
"""Validate the passed selection."""
|
||||
language: str = vol.Schema(str)(data)
|
||||
if "languages" in self.config and language not in self.config["languages"]:
|
||||
raise vol.Invalid(f"Value {language} is not a valid option")
|
||||
return language
|
||||
|
||||
|
||||
class LocationSelectorConfig(TypedDict, total=False):
|
||||
"""Class to represent a location selector config."""
|
||||
|
||||
|
||||
@@ -25,8 +25,8 @@ ha-av==10.0.0
|
||||
hass-nabucasa==0.66.2
|
||||
hassil==1.0.6
|
||||
home-assistant-bluetooth==1.10.0
|
||||
home-assistant-frontend==20230411.1
|
||||
home-assistant-intents==2023.4.17-1
|
||||
home-assistant-frontend==20230428.0
|
||||
home-assistant-intents==2023.4.26
|
||||
httpx==0.24.0
|
||||
ifaddr==0.1.7
|
||||
janus==1.0.0
|
||||
@@ -45,13 +45,13 @@ pyudev==0.23.2
|
||||
pyyaml==6.0
|
||||
requests==2.28.2
|
||||
scapy==2.5.0
|
||||
sqlalchemy==2.0.10
|
||||
sqlalchemy==2.0.11
|
||||
typing-extensions>=4.5.0,<5.0
|
||||
ulid-transform==0.7.0
|
||||
voluptuous-serialize==2.6.0
|
||||
voluptuous==0.13.1
|
||||
webrtcvad==2.0.10
|
||||
yarl==1.9.1
|
||||
yarl==1.9.2
|
||||
zeroconf==0.58.2
|
||||
|
||||
# Constrain pycryptodome to avoid vulnerability
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.5.0.dev0"
|
||||
version = "2023.5.0b2"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
@@ -53,7 +53,7 @@ dependencies = [
|
||||
"ulid-transform==0.7.0",
|
||||
"voluptuous==0.13.1",
|
||||
"voluptuous-serialize==2.6.0",
|
||||
"yarl==1.9.1",
|
||||
"yarl==1.9.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
+1
-1
@@ -27,4 +27,4 @@ typing-extensions>=4.5.0,<5.0
|
||||
ulid-transform==0.7.0
|
||||
voluptuous==0.13.1
|
||||
voluptuous-serialize==2.6.0
|
||||
yarl==1.9.1
|
||||
yarl==1.9.2
|
||||
|
||||
@@ -428,7 +428,7 @@ beautifulsoup4==4.11.1
|
||||
# beewi_smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.35.1
|
||||
bellows==0.35.2
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.13.0
|
||||
@@ -911,10 +911,10 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230411.1
|
||||
home-assistant-frontend==20230428.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.4.17-1
|
||||
home-assistant-intents==2023.4.26
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
@@ -1034,7 +1034,7 @@ krakenex==2.1.0
|
||||
lacrosse-view==0.0.9
|
||||
|
||||
# homeassistant.components.eufy
|
||||
lakeside==0.12
|
||||
lakeside==0.13
|
||||
|
||||
# homeassistant.components.laundrify
|
||||
laundrify_aio==1.1.2
|
||||
@@ -2108,7 +2108,7 @@ python-qbittorrent==0.4.2
|
||||
python-ripple-api==0.0.3
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==0.6.5
|
||||
python-roborock==0.8.3
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.33
|
||||
@@ -2176,7 +2176,7 @@ pyversasense==0.0.6
|
||||
pyvesync==2.1.1
|
||||
|
||||
# homeassistant.components.vizio
|
||||
pyvizio==0.1.60
|
||||
pyvizio==0.1.61
|
||||
|
||||
# homeassistant.components.velux
|
||||
pyvlx==0.2.20
|
||||
@@ -2406,7 +2406,7 @@ spotipy==2.23.0
|
||||
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.sql
|
||||
sqlalchemy==2.0.10
|
||||
sqlalchemy==2.0.11
|
||||
|
||||
# homeassistant.components.srp_energy
|
||||
srpenergy==1.3.6
|
||||
@@ -2718,7 +2718,7 @@ zeroconf==0.58.2
|
||||
zeversolar==0.3.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.97
|
||||
zha-quirks==0.0.98
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong_hong_hvac==1.0.9
|
||||
|
||||
@@ -361,7 +361,7 @@ base36==0.1.1
|
||||
beautifulsoup4==4.11.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.35.1
|
||||
bellows==0.35.2
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.13.0
|
||||
@@ -700,10 +700,10 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230411.1
|
||||
home-assistant-frontend==20230428.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.4.17-1
|
||||
home-assistant-intents==2023.4.26
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.7.2
|
||||
@@ -1516,7 +1516,7 @@ python-picnic-api==1.1.0
|
||||
python-qbittorrent==0.4.2
|
||||
|
||||
# homeassistant.components.roborock
|
||||
python-roborock==0.6.5
|
||||
python-roborock==0.8.3
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.33
|
||||
@@ -1566,7 +1566,7 @@ pyvera==0.3.13
|
||||
pyvesync==2.1.1
|
||||
|
||||
# homeassistant.components.vizio
|
||||
pyvizio==0.1.60
|
||||
pyvizio==0.1.61
|
||||
|
||||
# homeassistant.components.volumio
|
||||
pyvolumio==0.1.5
|
||||
@@ -1730,7 +1730,7 @@ spotipy==2.23.0
|
||||
|
||||
# homeassistant.components.recorder
|
||||
# homeassistant.components.sql
|
||||
sqlalchemy==2.0.10
|
||||
sqlalchemy==2.0.11
|
||||
|
||||
# homeassistant.components.srp_energy
|
||||
srpenergy==1.3.6
|
||||
@@ -1964,7 +1964,7 @@ zeroconf==0.58.2
|
||||
zeversolar==0.3.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha-quirks==0.0.97
|
||||
zha-quirks==0.0.98
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.21.0
|
||||
|
||||
@@ -730,6 +730,52 @@ async def test_zeroconf_ip_change_via_secondary_identifier(
|
||||
assert len(mock_async_setup.mock_calls) == 2
|
||||
assert entry.data[CONF_ADDRESS] == "127.0.0.1"
|
||||
assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2"
|
||||
assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"}
|
||||
|
||||
|
||||
async def test_zeroconf_updates_identifiers_for_ignored_entries(
|
||||
hass: HomeAssistant, mock_scan
|
||||
) -> None:
|
||||
"""Test that an ignored config entry gets updated when the ip changes.
|
||||
|
||||
Instead of checking only the unique id, all the identifiers
|
||||
in the config entry are checked
|
||||
"""
|
||||
entry = MockConfigEntry(
|
||||
domain="apple_tv",
|
||||
unique_id="aa:bb:cc:dd:ee:ff",
|
||||
source=config_entries.SOURCE_IGNORE,
|
||||
data={CONF_IDENTIFIERS: ["mrpid"], CONF_ADDRESS: "127.0.0.2"},
|
||||
)
|
||||
unrelated_entry = MockConfigEntry(
|
||||
domain="apple_tv", unique_id="unrelated", data={CONF_ADDRESS: "127.0.0.2"}
|
||||
)
|
||||
unrelated_entry.add_to_hass(hass)
|
||||
entry.add_to_hass(hass)
|
||||
mock_scan.result = [
|
||||
create_conf(
|
||||
IPv4Address("127.0.0.1"), "Device", mrp_service(), airplay_service()
|
||||
)
|
||||
]
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.apple_tv.async_setup_entry", return_value=True
|
||||
) as mock_async_setup:
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": config_entries.SOURCE_ZEROCONF},
|
||||
data=DMAP_SERVICE,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == data_entry_flow.FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
assert (
|
||||
len(mock_async_setup.mock_calls) == 0
|
||||
) # Should not be called because entry is ignored
|
||||
assert entry.data[CONF_ADDRESS] == "127.0.0.1"
|
||||
assert unrelated_entry.data[CONF_ADDRESS] == "127.0.0.2"
|
||||
assert set(entry.data[CONF_IDENTIFIERS]) == {"airplayid", "mrpid"}
|
||||
|
||||
|
||||
async def test_zeroconf_add_existing_aborts(hass: HomeAssistant, dmap_device) -> None:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Tests for the Voice Assistant integration."""
|
||||
|
||||
MANY_LANGUAGES = [
|
||||
"ar",
|
||||
"bg",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
dict({
|
||||
'data': dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
}),
|
||||
'type': <PipelineEventType.RUN_START: 'run-start'>,
|
||||
}),
|
||||
@@ -91,7 +91,7 @@
|
||||
dict({
|
||||
'data': dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'test_name',
|
||||
'pipeline': <ANY>,
|
||||
}),
|
||||
'type': <PipelineEventType.RUN_START: 'run-start'>,
|
||||
}),
|
||||
@@ -178,7 +178,7 @@
|
||||
dict({
|
||||
'data': dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'test_name',
|
||||
'pipeline': <ANY>,
|
||||
}),
|
||||
'type': <PipelineEventType.RUN_START: 'run-start'>,
|
||||
}),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# name: test_audio_pipeline
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': 1,
|
||||
'timeout': 30,
|
||||
@@ -78,7 +78,7 @@
|
||||
# name: test_audio_pipeline_debug
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': 1,
|
||||
'timeout': 30,
|
||||
@@ -154,7 +154,7 @@
|
||||
# name: test_intent_failed
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': None,
|
||||
'timeout': 30,
|
||||
@@ -171,7 +171,7 @@
|
||||
# name: test_intent_timeout
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': None,
|
||||
'timeout': 0.1,
|
||||
@@ -217,7 +217,7 @@
|
||||
# name: test_stt_stream_failed
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': 1,
|
||||
'timeout': 30,
|
||||
@@ -240,7 +240,7 @@
|
||||
# name: test_text_only_pipeline
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': None,
|
||||
'timeout': 30,
|
||||
@@ -285,7 +285,7 @@
|
||||
# name: test_tts_failed
|
||||
dict({
|
||||
'language': 'en',
|
||||
'pipeline': 'Home Assistant',
|
||||
'pipeline': <ANY>,
|
||||
'runner_data': dict({
|
||||
'stt_binary_handler_id': None,
|
||||
'timeout': 30,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Test Voice Assistant init."""
|
||||
from dataclasses import asdict
|
||||
from unittest.mock import ANY
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -12,6 +13,19 @@ from .conftest import MockSttProvider, MockSttProviderEntity
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
def process_events(events: list[assist_pipeline.PipelineEvent]) -> list[dict]:
|
||||
"""Process events to remove dynamic values."""
|
||||
processed = []
|
||||
for event in events:
|
||||
as_dict = asdict(event)
|
||||
as_dict.pop("timestamp")
|
||||
if as_dict["type"] == assist_pipeline.PipelineEventType.RUN_START:
|
||||
as_dict["data"]["pipeline"] = ANY
|
||||
processed.append(as_dict)
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
async def test_pipeline_from_audio_stream_auto(
|
||||
hass: HomeAssistant,
|
||||
mock_stt_provider: MockSttProvider,
|
||||
@@ -45,13 +59,7 @@ async def test_pipeline_from_audio_stream_auto(
|
||||
audio_data(),
|
||||
)
|
||||
|
||||
processed = []
|
||||
for event in events:
|
||||
as_dict = asdict(event)
|
||||
as_dict.pop("timestamp")
|
||||
processed.append(as_dict)
|
||||
|
||||
assert processed == snapshot
|
||||
assert process_events(events) == snapshot
|
||||
assert mock_stt_provider.received == [b"part1", b"part2"]
|
||||
|
||||
|
||||
@@ -111,13 +119,7 @@ async def test_pipeline_from_audio_stream_legacy(
|
||||
pipeline_id=pipeline_id,
|
||||
)
|
||||
|
||||
processed = []
|
||||
for event in events:
|
||||
as_dict = asdict(event)
|
||||
as_dict.pop("timestamp")
|
||||
processed.append(as_dict)
|
||||
|
||||
assert processed == snapshot
|
||||
assert process_events(events) == snapshot
|
||||
assert mock_stt_provider.received == [b"part1", b"part2"]
|
||||
|
||||
|
||||
@@ -177,13 +179,7 @@ async def test_pipeline_from_audio_stream_entity(
|
||||
pipeline_id=pipeline_id,
|
||||
)
|
||||
|
||||
processed = []
|
||||
for event in events:
|
||||
as_dict = asdict(event)
|
||||
as_dict.pop("timestamp")
|
||||
processed.append(as_dict)
|
||||
|
||||
assert processed == snapshot
|
||||
assert process_events(events) == snapshot
|
||||
assert mock_stt_provider_entity.received == [b"part1", b"part2"]
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Websocket tests for Voice Assistant integration."""
|
||||
import asyncio
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -37,6 +37,7 @@ async def test_text_only_pipeline(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
@@ -101,6 +102,7 @@ async def test_audio_pipeline(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
@@ -196,6 +198,7 @@ async def test_intent_timeout(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
@@ -292,7 +295,7 @@ async def test_intent_failed(
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.conversation.async_converse",
|
||||
new=MagicMock(return_value=RuntimeError),
|
||||
side_effect=RuntimeError,
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
@@ -310,6 +313,7 @@ async def test_intent_failed(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
@@ -405,7 +409,7 @@ async def test_stt_provider_missing(
|
||||
"""Test events from a pipeline run with a non-existent STT provider."""
|
||||
with patch(
|
||||
"homeassistant.components.stt.async_get_provider",
|
||||
new=MagicMock(return_value=None),
|
||||
return_value=None,
|
||||
):
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
@@ -438,7 +442,7 @@ async def test_stt_stream_failed(
|
||||
|
||||
with patch(
|
||||
"tests.components.assist_pipeline.conftest.MockSttProvider.async_process_audio_stream",
|
||||
new=MagicMock(side_effect=RuntimeError),
|
||||
side_effect=RuntimeError,
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
@@ -458,6 +462,7 @@ async def test_stt_stream_failed(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
@@ -504,7 +509,7 @@ async def test_tts_failed(
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.media_source.async_resolve_media",
|
||||
new=MagicMock(return_value=RuntimeError),
|
||||
side_effect=RuntimeError,
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
@@ -522,6 +527,7 @@ async def test_tts_failed(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
@@ -1105,6 +1111,7 @@ async def test_audio_pipeline_debug(
|
||||
# run start
|
||||
msg = await client.receive_json()
|
||||
assert msg["event"]["type"] == "run-start"
|
||||
msg["event"]["data"]["pipeline"] = ANY
|
||||
assert msg["event"]["data"] == snapshot
|
||||
events.append(msg["event"])
|
||||
|
||||
|
||||
@@ -25,8 +25,14 @@ from homeassistant.components.fan import (
|
||||
SERVICE_SET_DIRECTION,
|
||||
SERVICE_SET_PERCENTAGE,
|
||||
SERVICE_SET_PRESET_MODE,
|
||||
FanEntityFeature,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_SUPPORTED_FEATURES,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
)
|
||||
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
@@ -211,9 +217,9 @@ async def test_turn_on_fan_preset_mode(hass: HomeAssistant) -> None:
|
||||
bond_device_id="test-device-id",
|
||||
props={"max_speed": 6},
|
||||
)
|
||||
assert hass.states.get("fan.name_1").attributes[ATTR_PRESET_MODES] == [
|
||||
PRESET_MODE_BREEZE
|
||||
]
|
||||
state = hass.states.get("fan.name_1")
|
||||
assert state.attributes[ATTR_PRESET_MODES] == [PRESET_MODE_BREEZE]
|
||||
assert state.attributes[ATTR_SUPPORTED_FEATURES] & FanEntityFeature.PRESET_MODE
|
||||
|
||||
with patch_bond_action() as mock_set_preset_mode, patch_bond_device_state():
|
||||
await turn_fan_on(hass, "fan.name_1", preset_mode=PRESET_MODE_BREEZE)
|
||||
|
||||
@@ -650,3 +650,101 @@ async def test_alexa_config_migrate_expose_entity_prefs_default_none(
|
||||
|
||||
entity_default = entity_registry.async_get(entity_default.entity_id)
|
||||
assert entity_default.options == {"cloud.alexa": {"should_expose": True}}
|
||||
|
||||
|
||||
async def test_alexa_config_migrate_expose_entity_prefs_default(
|
||||
hass: HomeAssistant,
|
||||
cloud_prefs: CloudPreferences,
|
||||
cloud_stub,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Alexa entity config."""
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
binary_sensor_supported = entity_registry.async_get_or_create(
|
||||
"binary_sensor",
|
||||
"test",
|
||||
"binary_sensor_supported",
|
||||
original_device_class="door",
|
||||
suggested_object_id="supported",
|
||||
)
|
||||
|
||||
binary_sensor_unsupported = entity_registry.async_get_or_create(
|
||||
"binary_sensor",
|
||||
"test",
|
||||
"binary_sensor_unsupported",
|
||||
original_device_class="battery",
|
||||
suggested_object_id="unsupported",
|
||||
)
|
||||
|
||||
light = entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"test",
|
||||
"unique",
|
||||
suggested_object_id="light",
|
||||
)
|
||||
|
||||
sensor_supported = entity_registry.async_get_or_create(
|
||||
"sensor",
|
||||
"test",
|
||||
"sensor_supported",
|
||||
original_device_class="temperature",
|
||||
suggested_object_id="supported",
|
||||
)
|
||||
|
||||
sensor_unsupported = entity_registry.async_get_or_create(
|
||||
"sensor",
|
||||
"test",
|
||||
"sensor_unsupported",
|
||||
original_device_class="battery",
|
||||
suggested_object_id="unsupported",
|
||||
)
|
||||
|
||||
water_heater = entity_registry.async_get_or_create(
|
||||
"water_heater",
|
||||
"test",
|
||||
"unique",
|
||||
suggested_object_id="water_heater",
|
||||
)
|
||||
|
||||
await cloud_prefs.async_update(
|
||||
alexa_enabled=True,
|
||||
alexa_report_state=False,
|
||||
alexa_settings_version=1,
|
||||
)
|
||||
|
||||
cloud_prefs._prefs[PREF_ALEXA_DEFAULT_EXPOSE] = [
|
||||
"binary_sensor",
|
||||
"light",
|
||||
"sensor",
|
||||
"water_heater",
|
||||
]
|
||||
conf = alexa_config.CloudAlexaConfig(
|
||||
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub
|
||||
)
|
||||
await conf.async_initialize()
|
||||
|
||||
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 == {
|
||||
"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}}
|
||||
|
||||
@@ -611,3 +611,106 @@ async def test_google_config_migrate_expose_entity_prefs_default_none(
|
||||
|
||||
entity_default = entity_registry.async_get(entity_default.entity_id)
|
||||
assert entity_default.options == {"cloud.google_assistant": {"should_expose": True}}
|
||||
|
||||
|
||||
async def test_google_config_migrate_expose_entity_prefs_default(
|
||||
hass: HomeAssistant,
|
||||
cloud_prefs: CloudPreferences,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migrating Google entity config."""
|
||||
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
binary_sensor_supported = entity_registry.async_get_or_create(
|
||||
"binary_sensor",
|
||||
"test",
|
||||
"binary_sensor_supported",
|
||||
original_device_class="door",
|
||||
suggested_object_id="supported",
|
||||
)
|
||||
|
||||
binary_sensor_unsupported = entity_registry.async_get_or_create(
|
||||
"binary_sensor",
|
||||
"test",
|
||||
"binary_sensor_unsupported",
|
||||
original_device_class="battery",
|
||||
suggested_object_id="unsupported",
|
||||
)
|
||||
|
||||
light = entity_registry.async_get_or_create(
|
||||
"light",
|
||||
"test",
|
||||
"unique",
|
||||
suggested_object_id="light",
|
||||
)
|
||||
|
||||
sensor_supported = entity_registry.async_get_or_create(
|
||||
"sensor",
|
||||
"test",
|
||||
"sensor_supported",
|
||||
original_device_class="temperature",
|
||||
suggested_object_id="supported",
|
||||
)
|
||||
|
||||
sensor_unsupported = entity_registry.async_get_or_create(
|
||||
"sensor",
|
||||
"test",
|
||||
"sensor_unsupported",
|
||||
original_device_class="battery",
|
||||
suggested_object_id="unsupported",
|
||||
)
|
||||
|
||||
water_heater = entity_registry.async_get_or_create(
|
||||
"water_heater",
|
||||
"test",
|
||||
"unique",
|
||||
suggested_object_id="water_heater",
|
||||
)
|
||||
|
||||
await cloud_prefs.async_update(
|
||||
google_enabled=True,
|
||||
google_report_state=False,
|
||||
google_settings_version=1,
|
||||
)
|
||||
|
||||
cloud_prefs._prefs[PREF_GOOGLE_DEFAULT_EXPOSE] = [
|
||||
"binary_sensor",
|
||||
"light",
|
||||
"sensor",
|
||||
"water_heater",
|
||||
]
|
||||
conf = CloudGoogleConfig(
|
||||
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||
)
|
||||
await conf.async_initialize()
|
||||
|
||||
binary_sensor_supported = entity_registry.async_get(
|
||||
binary_sensor_supported.entity_id
|
||||
)
|
||||
assert binary_sensor_supported.options == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
|
||||
binary_sensor_unsupported = entity_registry.async_get(
|
||||
binary_sensor_unsupported.entity_id
|
||||
)
|
||||
assert binary_sensor_unsupported.options == {
|
||||
"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 == {
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
|
||||
sensor_unsupported = entity_registry.async_get(sensor_unsupported.entity_id)
|
||||
assert sensor_unsupported.options == {
|
||||
"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}}
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.components.cloud.const import DOMAIN
|
||||
from homeassistant.components.google_assistant.helpers import GoogleEntity
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.location import LocationInfo
|
||||
|
||||
from . import mock_cloud, mock_cloud_prefs
|
||||
@@ -106,6 +107,8 @@ async def test_google_actions_sync_fails(
|
||||
async def test_login_view(hass: HomeAssistant, cloud_client) -> None:
|
||||
"""Test logging in when an assist pipeline is available."""
|
||||
hass.data["cloud"] = MagicMock(login=AsyncMock())
|
||||
await async_setup_component(hass, "stt", {})
|
||||
await async_setup_component(hass, "tts", {})
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.cloud.http_api.assist_pipeline.async_get_pipelines",
|
||||
@@ -126,13 +129,15 @@ async def test_login_view(hass: HomeAssistant, cloud_client) -> None:
|
||||
|
||||
assert req.status == HTTPStatus.OK
|
||||
result = await req.json()
|
||||
assert result == {"success": True, "cloud_pipeline": "12345"}
|
||||
assert result == {"success": True, "cloud_pipeline": None}
|
||||
create_pipeline_mock.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_login_view_create_pipeline(hass: HomeAssistant, cloud_client) -> None:
|
||||
"""Test logging in when no assist pipeline is available."""
|
||||
hass.data["cloud"] = MagicMock(login=AsyncMock())
|
||||
await async_setup_component(hass, "stt", {})
|
||||
await async_setup_component(hass, "tts", {})
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline",
|
||||
@@ -153,6 +158,8 @@ async def test_login_view_create_pipeline_fail(
|
||||
) -> None:
|
||||
"""Test logging in when no assist pipeline is available."""
|
||||
hass.data["cloud"] = MagicMock(login=AsyncMock())
|
||||
await async_setup_component(hass, "stt", {})
|
||||
await async_setup_component(hass, "tts", {})
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.cloud.http_api.assist_pipeline.async_create_default_pipeline",
|
||||
@@ -931,6 +938,67 @@ async def test_list_alexa_entities(
|
||||
}
|
||||
|
||||
|
||||
async def test_get_alexa_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
setup_api,
|
||||
mock_cloud_login,
|
||||
) -> None:
|
||||
"""Test that we can get an Alexa entity."""
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Test getting an unknown entity
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_found",
|
||||
"message": "light.kitchen not in the entity registry",
|
||||
}
|
||||
|
||||
# Test getting a blocked entity
|
||||
entity_registry.async_get_or_create(
|
||||
"group", "test", "unique", suggested_object_id="all_locks"
|
||||
)
|
||||
hass.states.async_set("group.all_locks", "bla")
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "group.all_locks"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_supported",
|
||||
"message": "group.all_locks not supported by Alexa",
|
||||
}
|
||||
|
||||
entity_registry.async_get_or_create(
|
||||
"light", "test", "unique", suggested_object_id="kitchen"
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
"water_heater", "test", "unique", suggested_object_id="basement"
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "light.kitchen"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] is None
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "cloud/alexa/entities/get", "entity_id": "water_heater.basement"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert not response["success"]
|
||||
assert response["error"] == {
|
||||
"code": "not_supported",
|
||||
"message": "water_heater.basement not supported by Alexa",
|
||||
}
|
||||
|
||||
|
||||
async def test_update_alexa_entity(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
|
||||
@@ -4,6 +4,9 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_get_assistant_settings,
|
||||
)
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME
|
||||
from homeassistant.core import DOMAIN as HASS_DOMAIN, Context, HomeAssistant
|
||||
from homeassistant.helpers import (
|
||||
@@ -137,3 +140,34 @@ async def test_conversation_agent(
|
||||
return_value={"homeassistant": ["dwarvish", "elvish", "entish"]},
|
||||
):
|
||||
assert agent.supported_languages == ["dwarvish", "elvish", "entish"]
|
||||
|
||||
|
||||
async def test_expose_flag_automatically_set(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test DefaultAgent sets the expose flag on all entities automatically."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
light = entity_registry.async_get_or_create("light", "demo", "1234")
|
||||
test = entity_registry.async_get_or_create("test", "demo", "1234")
|
||||
|
||||
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {}
|
||||
|
||||
assert await async_setup_component(hass, "conversation", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# After setting up conversation, the expose flag should now be set on all entities
|
||||
assert async_get_assistant_settings(hass, conversation.DOMAIN) == {
|
||||
light.entity_id: {"should_expose": True},
|
||||
test.entity_id: {"should_expose": False},
|
||||
}
|
||||
|
||||
# New entities will automatically have the expose flag set
|
||||
new_light = entity_registry.async_get_or_create("light", "demo", "2345")
|
||||
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},
|
||||
test.entity_id: {"should_expose": False},
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import IntegrationNotFound
|
||||
from homeassistant.requirements import RequirementsNotFound
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import (
|
||||
@@ -1554,3 +1556,25 @@ async def test_automation_with_device_component_not_loaded(
|
||||
)
|
||||
|
||||
module.async_validate_trigger_config.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
[
|
||||
IntegrationNotFound("test"),
|
||||
RequirementsNotFound("test", []),
|
||||
ImportError("test"),
|
||||
],
|
||||
)
|
||||
async def test_async_get_device_automations_platform_reraises_exceptions(
|
||||
hass: HomeAssistant, exc: Exception
|
||||
) -> None:
|
||||
"""Test InvalidDeviceAutomationConfig is raised when async_get_integration_with_requirements fails."""
|
||||
await async_setup_component(hass, "device_automation", {})
|
||||
with patch(
|
||||
"homeassistant.components.device_automation.async_get_integration_with_requirements",
|
||||
side_effect=exc,
|
||||
), pytest.raises(InvalidDeviceAutomationConfig):
|
||||
await device_automation.async_get_device_automation_platform(
|
||||
hass, "test", device_automation.DeviceAutomationType.TRIGGER
|
||||
)
|
||||
|
||||
@@ -157,3 +157,38 @@ async def mock_voice_assistant_v1_entry(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_voice_assistant_v2_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_client,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up an ESPHome entry with voice assistant."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: "test.local",
|
||||
CONF_PORT: 6053,
|
||||
CONF_PASSWORD: "",
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
device_info = DeviceInfo(
|
||||
name="test",
|
||||
friendly_name="Test",
|
||||
voice_assistant_version=2,
|
||||
mac_address="11:22:33:44:55:aa",
|
||||
esphome_version="1.0.0",
|
||||
)
|
||||
|
||||
mock_client.device_info = AsyncMock(return_value=device_info)
|
||||
mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock())
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return entry
|
||||
|
||||
@@ -5,24 +5,24 @@ from homeassistant.components.esphome import DomainData
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
|
||||
async def test_call_active(
|
||||
async def test_assist_in_progress(
|
||||
hass: HomeAssistant,
|
||||
mock_voice_assistant_v1_entry,
|
||||
) -> None:
|
||||
"""Test call active binary sensor."""
|
||||
"""Test assist in progress binary sensor."""
|
||||
|
||||
entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry)
|
||||
|
||||
state = hass.states.get("binary_sensor.test_call_active")
|
||||
state = hass.states.get("binary_sensor.test_assist_in_progress")
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
|
||||
entry_data.async_set_assist_pipeline_state(True)
|
||||
|
||||
state = hass.states.get("binary_sensor.test_call_active")
|
||||
state = hass.states.get("binary_sensor.test_assist_in_progress")
|
||||
assert state.state == "on"
|
||||
|
||||
entry_data.async_set_assist_pipeline_state(False)
|
||||
|
||||
state = hass.states.get("binary_sensor.test_call_active")
|
||||
state = hass.states.get("binary_sensor.test_assist_in_progress")
|
||||
assert state.state == "off"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Test ESPHome update entities."""
|
||||
import asyncio
|
||||
import dataclasses
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
@@ -197,3 +198,43 @@ async def test_update_device_state_for_availability(
|
||||
|
||||
state = hass.states.get("update.none_firmware")
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
async def test_update_entity_dashboard_not_available_startup(
|
||||
hass: HomeAssistant, mock_config_entry, mock_device_info, mock_dashboard
|
||||
) -> None:
|
||||
"""Test ESPHome update entity when dashboard is not available at startup."""
|
||||
with patch(
|
||||
"homeassistant.components.esphome.update.DomainData.get_entry_data",
|
||||
return_value=Mock(available=True, device_info=mock_device_info),
|
||||
), patch(
|
||||
"esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
|
||||
side_effect=asyncio.TimeoutError,
|
||||
):
|
||||
await async_get_dashboard(hass).async_refresh()
|
||||
assert await hass.config_entries.async_forward_entry_setup(
|
||||
mock_config_entry, "update"
|
||||
)
|
||||
|
||||
state = hass.states.get("update.none_firmware")
|
||||
assert state is None
|
||||
|
||||
mock_dashboard["configured"] = [
|
||||
{
|
||||
"name": "test",
|
||||
"current_version": "2023.2.0-dev",
|
||||
"configuration": "test.yaml",
|
||||
}
|
||||
]
|
||||
await async_get_dashboard(hass).async_refresh()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("update.none_firmware")
|
||||
assert state.state == "on"
|
||||
expected_attributes = {
|
||||
"latest_version": "2023.2.0-dev",
|
||||
"installed_version": "1.0.0",
|
||||
"supported_features": UpdateEntityFeature.INSTALL,
|
||||
}
|
||||
for key, expected_value in expected_attributes.items():
|
||||
assert state.attributes.get(key) == expected_value
|
||||
|
||||
@@ -4,18 +4,64 @@ import asyncio
|
||||
import socket
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from aioesphomeapi import VoiceAssistantEventType
|
||||
import async_timeout
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import assist_pipeline, esphome
|
||||
from homeassistant.components import esphome
|
||||
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
|
||||
from homeassistant.components.esphome import DomainData
|
||||
from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
_TEST_INPUT_TEXT = "This is an input test"
|
||||
_TEST_OUTPUT_TEXT = "This is an output test"
|
||||
_TEST_OUTPUT_URL = "output.mp3"
|
||||
_TEST_MEDIA_ID = "12345"
|
||||
|
||||
|
||||
async def test_pipeline_events(hass: HomeAssistant) -> None:
|
||||
@pytest.fixture
|
||||
def voice_assistant_udp_server_v1(
|
||||
hass: HomeAssistant,
|
||||
mock_voice_assistant_v1_entry,
|
||||
) -> VoiceAssistantUDPServer:
|
||||
"""Return the UDP server."""
|
||||
entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry)
|
||||
|
||||
server: VoiceAssistantUDPServer = None
|
||||
|
||||
def handle_finished():
|
||||
nonlocal server
|
||||
assert server is not None
|
||||
server.close()
|
||||
|
||||
server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished)
|
||||
return server
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def voice_assistant_udp_server_v2(
|
||||
hass: HomeAssistant,
|
||||
mock_voice_assistant_v2_entry,
|
||||
) -> VoiceAssistantUDPServer:
|
||||
"""Return the UDP server."""
|
||||
entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v2_entry)
|
||||
|
||||
server: VoiceAssistantUDPServer = None
|
||||
|
||||
def handle_finished():
|
||||
nonlocal server
|
||||
assert server is not None
|
||||
server.close()
|
||||
|
||||
server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished)
|
||||
return server
|
||||
|
||||
|
||||
async def test_pipeline_events(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test that the pipeline function is called."""
|
||||
|
||||
async def async_pipeline_from_audio_stream(*args, **kwargs):
|
||||
@@ -23,29 +69,29 @@ async def test_pipeline_events(hass: HomeAssistant) -> None:
|
||||
|
||||
# Fake events
|
||||
event_callback(
|
||||
assist_pipeline.PipelineEvent(
|
||||
type=assist_pipeline.PipelineEventType.STT_START,
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.STT_START,
|
||||
data={},
|
||||
)
|
||||
)
|
||||
|
||||
event_callback(
|
||||
assist_pipeline.PipelineEvent(
|
||||
type=assist_pipeline.PipelineEventType.STT_END,
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.STT_END,
|
||||
data={"stt_output": {"text": _TEST_INPUT_TEXT}},
|
||||
)
|
||||
)
|
||||
|
||||
event_callback(
|
||||
assist_pipeline.PipelineEvent(
|
||||
type=assist_pipeline.PipelineEventType.TTS_START,
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.TTS_START,
|
||||
data={"tts_input": _TEST_OUTPUT_TEXT},
|
||||
)
|
||||
)
|
||||
|
||||
event_callback(
|
||||
assist_pipeline.PipelineEvent(
|
||||
type=assist_pipeline.PipelineEventType.TTS_END,
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.TTS_END,
|
||||
data={"tts_output": {"url": _TEST_OUTPUT_URL}},
|
||||
)
|
||||
)
|
||||
@@ -63,79 +109,229 @@ async def test_pipeline_events(hass: HomeAssistant) -> None:
|
||||
assert data is not None
|
||||
assert data["url"] == _TEST_OUTPUT_URL
|
||||
|
||||
voice_assistant_udp_server_v1.handle_event = handle_event
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream",
|
||||
new=async_pipeline_from_audio_stream,
|
||||
):
|
||||
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
|
||||
server.transport = Mock()
|
||||
voice_assistant_udp_server_v1.transport = Mock()
|
||||
|
||||
await server.run_pipeline(handle_event)
|
||||
await voice_assistant_udp_server_v1.run_pipeline()
|
||||
|
||||
|
||||
async def test_udp_server(
|
||||
hass: HomeAssistant,
|
||||
socket_enabled,
|
||||
unused_udp_port_factory,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server runs and queues incoming data."""
|
||||
port_to_use = unused_udp_port_factory()
|
||||
|
||||
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.UDP_PORT", new=port_to_use
|
||||
):
|
||||
port = await server.start_server()
|
||||
port = await voice_assistant_udp_server_v1.start_server()
|
||||
assert port == port_to_use
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
assert server.queue.qsize() == 0
|
||||
assert voice_assistant_udp_server_v1.queue.qsize() == 0
|
||||
sock.sendto(b"test", ("127.0.0.1", port))
|
||||
|
||||
# Give the socket some time to send/receive the data
|
||||
async with async_timeout.timeout(1):
|
||||
while server.queue.qsize() == 0:
|
||||
while voice_assistant_udp_server_v1.queue.qsize() == 0:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
assert server.queue.qsize() == 1
|
||||
assert voice_assistant_udp_server_v1.queue.qsize() == 1
|
||||
|
||||
server.stop()
|
||||
voice_assistant_udp_server_v1.stop()
|
||||
voice_assistant_udp_server_v1.close()
|
||||
|
||||
assert server.transport.is_closing()
|
||||
assert voice_assistant_udp_server_v1.transport.is_closing()
|
||||
|
||||
|
||||
async def test_udp_server_queue(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server queues incoming data."""
|
||||
|
||||
voice_assistant_udp_server_v1.started = True
|
||||
|
||||
assert voice_assistant_udp_server_v1.queue.qsize() == 0
|
||||
|
||||
voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0))
|
||||
assert voice_assistant_udp_server_v1.queue.qsize() == 1
|
||||
|
||||
voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0))
|
||||
assert voice_assistant_udp_server_v1.queue.qsize() == 2
|
||||
|
||||
async for data in voice_assistant_udp_server_v1._iterate_packets():
|
||||
assert data == bytes(1024)
|
||||
break
|
||||
assert voice_assistant_udp_server_v1.queue.qsize() == 1 # One message removed
|
||||
|
||||
voice_assistant_udp_server_v1.stop()
|
||||
assert (
|
||||
voice_assistant_udp_server_v1.queue.qsize() == 2
|
||||
) # An empty message added by stop
|
||||
|
||||
voice_assistant_udp_server_v1.datagram_received(bytes(1024), ("localhost", 0))
|
||||
assert (
|
||||
voice_assistant_udp_server_v1.queue.qsize() == 2
|
||||
) # No new messages added after stop
|
||||
|
||||
voice_assistant_udp_server_v1.close()
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
async for data in voice_assistant_udp_server_v1._iterate_packets():
|
||||
assert data == bytes(1024)
|
||||
|
||||
|
||||
async def test_error_calls_handle_finished(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test that the handle_finished callback is called when an error occurs."""
|
||||
voice_assistant_udp_server_v1.handle_finished = Mock()
|
||||
|
||||
voice_assistant_udp_server_v1.error_received(Exception())
|
||||
|
||||
voice_assistant_udp_server_v1.handle_finished.assert_called()
|
||||
|
||||
|
||||
async def test_udp_server_multiple(
|
||||
hass: HomeAssistant,
|
||||
socket_enabled,
|
||||
unused_udp_port_factory,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test that the UDP server raises an error if started twice."""
|
||||
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.UDP_PORT",
|
||||
new=unused_udp_port_factory(),
|
||||
):
|
||||
await server.start_server()
|
||||
await voice_assistant_udp_server_v1.start_server()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.UDP_PORT",
|
||||
new=unused_udp_port_factory(),
|
||||
), pytest.raises(RuntimeError):
|
||||
pass
|
||||
await server.start_server()
|
||||
await voice_assistant_udp_server_v1.start_server()
|
||||
|
||||
|
||||
async def test_udp_server_after_stopped(
|
||||
hass: HomeAssistant,
|
||||
socket_enabled,
|
||||
unused_udp_port_factory,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test that the UDP server raises an error if started after stopped."""
|
||||
server = esphome.voice_assistant.VoiceAssistantUDPServer(hass)
|
||||
server.stop()
|
||||
voice_assistant_udp_server_v1.close()
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.UDP_PORT",
|
||||
new=unused_udp_port_factory(),
|
||||
), pytest.raises(RuntimeError):
|
||||
await server.start_server()
|
||||
await voice_assistant_udp_server_v1.start_server()
|
||||
|
||||
|
||||
async def test_unknown_event_type(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server does not call handle_event for unknown events."""
|
||||
voice_assistant_udp_server_v1._event_callback(
|
||||
PipelineEvent(
|
||||
type="unknown-event",
|
||||
data={},
|
||||
)
|
||||
)
|
||||
|
||||
assert not voice_assistant_udp_server_v1.handle_event.called
|
||||
|
||||
|
||||
async def test_error_event_type(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server calls event handler with error."""
|
||||
voice_assistant_udp_server_v1._event_callback(
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.ERROR,
|
||||
data={"code": "code", "message": "message"},
|
||||
)
|
||||
)
|
||||
|
||||
assert voice_assistant_udp_server_v1.handle_event.called_with(
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_ERROR,
|
||||
{"code": "code", "message": "message"},
|
||||
)
|
||||
|
||||
|
||||
async def test_send_tts_not_called(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v1: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server with a v1 device does not call _send_tts."""
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts"
|
||||
) as mock_send_tts:
|
||||
voice_assistant_udp_server_v1._event_callback(
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.TTS_END,
|
||||
data={
|
||||
"tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
mock_send_tts.assert_not_called()
|
||||
|
||||
|
||||
async def test_send_tts_called(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v2: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server with a v2 device calls _send_tts."""
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.VoiceAssistantUDPServer._send_tts"
|
||||
) as mock_send_tts:
|
||||
voice_assistant_udp_server_v2._event_callback(
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.TTS_END,
|
||||
data={
|
||||
"tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
mock_send_tts.assert_called_with(_TEST_MEDIA_ID)
|
||||
|
||||
|
||||
async def test_send_tts(
|
||||
hass: HomeAssistant,
|
||||
voice_assistant_udp_server_v2: VoiceAssistantUDPServer,
|
||||
) -> None:
|
||||
"""Test the UDP server calls sendto to transmit audio data to device."""
|
||||
with patch(
|
||||
"homeassistant.components.esphome.voice_assistant.tts.async_get_media_source_audio",
|
||||
return_value=("raw", bytes(1024)),
|
||||
):
|
||||
voice_assistant_udp_server_v2.transport = Mock(spec=asyncio.DatagramTransport)
|
||||
|
||||
voice_assistant_udp_server_v2._event_callback(
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.TTS_END,
|
||||
data={
|
||||
"tts_output": {"media_id": _TEST_MEDIA_ID, "url": _TEST_OUTPUT_URL}
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
await voice_assistant_udp_server_v2._tts_done.wait()
|
||||
|
||||
voice_assistant_udp_server_v2.transport.sendto.assert_called()
|
||||
|
||||
@@ -72,6 +72,7 @@ def mock_forecast_solar(hass) -> Generator[None, MagicMock, None]:
|
||||
estimate.api_rate_limit = 60
|
||||
estimate.account_type.value = "public"
|
||||
estimate.energy_production_today = 100000
|
||||
estimate.energy_production_today_remaining = 50000
|
||||
estimate.energy_production_tomorrow = 200000
|
||||
estimate.power_production_now = 300000
|
||||
estimate.power_highest_peak_time_today = datetime(
|
||||
|
||||
@@ -34,6 +34,7 @@ async def test_diagnostics(
|
||||
},
|
||||
"data": {
|
||||
"energy_production_today": 100000,
|
||||
"energy_production_today_remaining": 50000,
|
||||
"energy_production_tomorrow": 200000,
|
||||
"energy_current_hour": 800000,
|
||||
"power_production_now": 300000,
|
||||
|
||||
@@ -48,6 +48,21 @@ async def test_sensors(
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY
|
||||
assert ATTR_ICON not in state.attributes
|
||||
|
||||
state = hass.states.get("sensor.energy_production_today_remaining")
|
||||
entry = entity_registry.async_get("sensor.energy_production_today_remaining")
|
||||
assert entry
|
||||
assert state
|
||||
assert entry.unique_id == f"{entry_id}_energy_production_today_remaining"
|
||||
assert state.state == "50.0"
|
||||
assert (
|
||||
state.attributes.get(ATTR_FRIENDLY_NAME)
|
||||
== "Solar production forecast Estimated energy production - remaining today"
|
||||
)
|
||||
assert state.attributes.get(ATTR_STATE_CLASS) is None
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR
|
||||
assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY
|
||||
assert ATTR_ICON not in state.attributes
|
||||
|
||||
state = hass.states.get("sensor.energy_production_tomorrow")
|
||||
entry = entity_registry.async_get("sensor.energy_production_tomorrow")
|
||||
assert entry
|
||||
|
||||
@@ -7,7 +7,9 @@ import aiohttp
|
||||
from aiohttp import hdrs, web
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hassio import handler
|
||||
from homeassistant.components.hassio.handler import HassIO, HassioAPIError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
@@ -360,3 +362,54 @@ async def test_api_headers(
|
||||
assert received_request.headers[hdrs.CONTENT_TYPE] == "application/json"
|
||||
else:
|
||||
assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream"
|
||||
|
||||
|
||||
async def test_api_get_yellow_settings(
|
||||
hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test setup with API ping."""
|
||||
aioclient_mock.get(
|
||||
"http://127.0.0.1/os/boards/yellow",
|
||||
json={
|
||||
"result": "ok",
|
||||
"data": {"disk_led": True, "heartbeat_led": True, "power_led": True},
|
||||
},
|
||||
)
|
||||
|
||||
assert await handler.async_get_yellow_settings(hass) == {
|
||||
"disk_led": True,
|
||||
"heartbeat_led": True,
|
||||
"power_led": True,
|
||||
}
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
|
||||
async def test_api_set_yellow_settings(
|
||||
hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test setup with API ping."""
|
||||
aioclient_mock.post(
|
||||
"http://127.0.0.1/os/boards/yellow",
|
||||
json={"result": "ok", "data": {}},
|
||||
)
|
||||
|
||||
assert (
|
||||
await handler.async_set_yellow_settings(
|
||||
hass, {"disk_led": True, "heartbeat_led": True, "power_led": True}
|
||||
)
|
||||
== {}
|
||||
)
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
|
||||
async def test_api_reboot_host(
|
||||
hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker
|
||||
) -> None:
|
||||
"""Test setup with API ping."""
|
||||
aioclient_mock.post(
|
||||
"http://127.0.0.1/host/reboot",
|
||||
json={"result": "ok", "data": {}},
|
||||
)
|
||||
|
||||
assert await handler.async_reboot_host(hass) == {}
|
||||
assert aioclient_mock.call_count == 1
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Test the Home Assistant Yellow config flow."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.homeassistant_yellow.const import DOMAIN
|
||||
from homeassistant.components.zha.core.const import DOMAIN as ZHA_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -9,6 +11,34 @@ from homeassistant.data_entry_flow import FlowResultType
|
||||
from tests.common import MockConfigEntry, MockModule, mock_integration
|
||||
|
||||
|
||||
@pytest.fixture(name="get_yellow_settings")
|
||||
def mock_get_yellow_settings():
|
||||
"""Mock getting yellow settings."""
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings",
|
||||
return_value={"disk_led": True, "heartbeat_led": True, "power_led": True},
|
||||
) as get_yellow_settings:
|
||||
yield get_yellow_settings
|
||||
|
||||
|
||||
@pytest.fixture(name="set_yellow_settings")
|
||||
def mock_set_yellow_settings():
|
||||
"""Mock setting yellow settings."""
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings",
|
||||
) as set_yellow_settings:
|
||||
yield set_yellow_settings
|
||||
|
||||
|
||||
@pytest.fixture(name="reboot_host")
|
||||
def mock_reboot_host():
|
||||
"""Mock rebooting host."""
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_yellow.config_flow.async_reboot_host",
|
||||
) as reboot_host:
|
||||
yield reboot_host
|
||||
|
||||
|
||||
async def test_config_flow(hass: HomeAssistant) -> None:
|
||||
"""Test the config flow."""
|
||||
mock_integration(hass, MockModule("hassio"))
|
||||
@@ -79,11 +109,17 @@ async def test_option_flow_install_multi_pan_addon(
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio",
|
||||
side_effect=Mock(return_value=True),
|
||||
):
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "multipan_settings"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "addon_not_installed"
|
||||
|
||||
@@ -155,11 +191,17 @@ async def test_option_flow_install_multi_pan_addon_zha(
|
||||
)
|
||||
zha_config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio",
|
||||
side_effect=Mock(return_value=True),
|
||||
):
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "multipan_settings"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "addon_not_installed"
|
||||
|
||||
@@ -210,3 +252,156 @@ async def test_option_flow_install_multi_pan_addon_zha(
|
||||
|
||||
result = await hass.config_entries.options.async_configure(result["flow_id"])
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("reboot_menu_choice", "reboot_calls"),
|
||||
[("reboot_now", 1), ("reboot_later", 0)],
|
||||
)
|
||||
async def test_option_flow_led_settings(
|
||||
hass: HomeAssistant,
|
||||
get_yellow_settings,
|
||||
set_yellow_settings,
|
||||
reboot_host,
|
||||
reboot_menu_choice,
|
||||
reboot_calls,
|
||||
) -> None:
|
||||
"""Test updating LED settings."""
|
||||
mock_integration(hass, MockModule("hassio"))
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Yellow",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "main_menu"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "hardware_settings"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"disk_led": False, "heartbeat_led": False, "power_led": False},
|
||||
)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "reboot_menu"
|
||||
set_yellow_settings.assert_called_once_with(
|
||||
hass, {"disk_led": False, "heartbeat_led": False, "power_led": False}
|
||||
)
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": reboot_menu_choice},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert len(reboot_host.mock_calls) == reboot_calls
|
||||
|
||||
|
||||
async def test_option_flow_led_settings_unchanged(
|
||||
hass: HomeAssistant,
|
||||
get_yellow_settings,
|
||||
set_yellow_settings,
|
||||
) -> None:
|
||||
"""Test updating LED settings."""
|
||||
mock_integration(hass, MockModule("hassio"))
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Yellow",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "main_menu"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "hardware_settings"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"disk_led": True, "heartbeat_led": True, "power_led": True},
|
||||
)
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
set_yellow_settings.assert_not_called()
|
||||
|
||||
|
||||
async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None:
|
||||
"""Test updating LED settings."""
|
||||
mock_integration(hass, MockModule("hassio"))
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Yellow",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "main_menu"
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_yellow.config_flow.async_get_yellow_settings",
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "hardware_settings"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "read_hw_settings_error"
|
||||
|
||||
|
||||
async def test_option_flow_led_settings_fail_2(
|
||||
hass: HomeAssistant, get_yellow_settings
|
||||
) -> None:
|
||||
"""Test updating LED settings."""
|
||||
mock_integration(hass, MockModule("hassio"))
|
||||
|
||||
# Setup the config entry
|
||||
config_entry = MockConfigEntry(
|
||||
data={},
|
||||
domain=DOMAIN,
|
||||
options={},
|
||||
title="Home Assistant Yellow",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(config_entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "main_menu"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "hardware_settings"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.homeassistant_yellow.config_flow.async_set_yellow_settings",
|
||||
side_effect=TimeoutError,
|
||||
):
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"disk_led": False, "heartbeat_led": False, "power_led": False},
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "write_hw_settings_error"
|
||||
|
||||
@@ -18,9 +18,10 @@ from homeassistant.components.lutron_caseta.const import (
|
||||
from homeassistant.components.lutron_caseta.models import LutronCasetaData
|
||||
from homeassistant.const import ATTR_DEVICE_ID, CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import MockBridge
|
||||
from . import MockBridge, async_setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.logbook.common import MockRow, mock_humanify
|
||||
@@ -78,3 +79,134 @@ async def test_humanify_lutron_caseta_button_event(hass: HomeAssistant) -> None:
|
||||
assert event1["name"] == "Dining Room Pico"
|
||||
assert event1["domain"] == DOMAIN
|
||||
assert event1["message"] == "press stop"
|
||||
|
||||
|
||||
async def test_humanify_lutron_caseta_button_event_integration_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test humanifying lutron_caseta_button_events when the integration fails to load."""
|
||||
hass.config.components.add("recorder")
|
||||
assert await async_setup_component(hass, "logbook", {})
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={
|
||||
CONF_HOST: "1.1.1.1",
|
||||
CONF_KEYFILE: "",
|
||||
CONF_CERTFILE: "",
|
||||
CONF_CA_CERTS: "",
|
||||
},
|
||||
unique_id="abc",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.lutron_caseta.Smartbridge.create_tls",
|
||||
return_value=MockBridge(can_connect=True),
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in device_registry.devices.values():
|
||||
if device.config_entries == {config_entry.entry_id}:
|
||||
dr_device_id = device.id
|
||||
break
|
||||
|
||||
assert dr_device_id is not None
|
||||
(event1,) = mock_humanify(
|
||||
hass,
|
||||
[
|
||||
MockRow(
|
||||
LUTRON_CASETA_BUTTON_EVENT,
|
||||
{
|
||||
ATTR_SERIAL: "68551522",
|
||||
ATTR_DEVICE_ID: dr_device_id,
|
||||
ATTR_TYPE: "Pico3ButtonRaiseLower",
|
||||
ATTR_LEAP_BUTTON_NUMBER: 1,
|
||||
ATTR_BUTTON_NUMBER: 1,
|
||||
ATTR_DEVICE_NAME: "Pico",
|
||||
ATTR_AREA_NAME: "Dining Room",
|
||||
ATTR_ACTION: "press",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert event1["name"] == "Dining Room Pico"
|
||||
assert event1["domain"] == DOMAIN
|
||||
assert event1["message"] == "press stop"
|
||||
|
||||
|
||||
async def test_humanify_lutron_caseta_button_event_ra3(hass: HomeAssistant) -> None:
|
||||
"""Test humanifying lutron_caseta_button_events from an RA3 hub."""
|
||||
hass.config.components.add("recorder")
|
||||
assert await async_setup_component(hass, "logbook", {})
|
||||
await async_setup_integration(hass, MockBridge)
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
keypad = registry.async_get_device(
|
||||
identifiers={(DOMAIN, 66286451)}, connections=set()
|
||||
)
|
||||
assert keypad
|
||||
|
||||
(event1,) = mock_humanify(
|
||||
hass,
|
||||
[
|
||||
MockRow(
|
||||
LUTRON_CASETA_BUTTON_EVENT,
|
||||
{
|
||||
ATTR_SERIAL: "66286451",
|
||||
ATTR_DEVICE_ID: keypad.id,
|
||||
ATTR_TYPE: keypad.model,
|
||||
ATTR_LEAP_BUTTON_NUMBER: 3,
|
||||
ATTR_BUTTON_NUMBER: 3,
|
||||
ATTR_DEVICE_NAME: "Keypad",
|
||||
ATTR_AREA_NAME: "Breakfast",
|
||||
ATTR_ACTION: "press",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert event1["name"] == "Breakfast Keypad"
|
||||
assert event1["domain"] == DOMAIN
|
||||
assert event1["message"] == "press Kitchen Pendants"
|
||||
|
||||
|
||||
async def test_humanify_lutron_caseta_button_unknown_type(hass: HomeAssistant) -> None:
|
||||
"""Test humanifying lutron_caseta_button_events with an unknown type."""
|
||||
hass.config.components.add("recorder")
|
||||
assert await async_setup_component(hass, "logbook", {})
|
||||
await async_setup_integration(hass, MockBridge)
|
||||
|
||||
registry = dr.async_get(hass)
|
||||
keypad = registry.async_get_device(
|
||||
identifiers={(DOMAIN, 66286451)}, connections=set()
|
||||
)
|
||||
assert keypad
|
||||
|
||||
(event1,) = mock_humanify(
|
||||
hass,
|
||||
[
|
||||
MockRow(
|
||||
LUTRON_CASETA_BUTTON_EVENT,
|
||||
{
|
||||
ATTR_SERIAL: "66286451",
|
||||
ATTR_DEVICE_ID: "removed",
|
||||
ATTR_TYPE: keypad.model,
|
||||
ATTR_LEAP_BUTTON_NUMBER: 3,
|
||||
ATTR_BUTTON_NUMBER: 3,
|
||||
ATTR_DEVICE_NAME: "Keypad",
|
||||
ATTR_AREA_NAME: "Breakfast",
|
||||
ATTR_ACTION: "press",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
assert event1["name"] == "Breakfast Keypad"
|
||||
assert event1["domain"] == DOMAIN
|
||||
assert event1["message"] == "press Error retrieving button description"
|
||||
|
||||
@@ -103,7 +103,7 @@ async def test_cover(
|
||||
assert matter_client.send_device_command.call_args == call(
|
||||
node_id=window_covering.node_id,
|
||||
endpoint_id=1,
|
||||
command=clusters.WindowCovering.Commands.GoToLiftValue(50),
|
||||
command=clusters.WindowCovering.Commands.GoToLiftPercentage(5000),
|
||||
)
|
||||
matter_client.send_device_command.reset_mock()
|
||||
|
||||
@@ -121,7 +121,7 @@ async def test_cover(
|
||||
|
||||
state = hass.states.get("cover.longan_link_wncv_da01")
|
||||
assert state
|
||||
assert state.state == STATE_CLOSED
|
||||
assert state.state == STATE_OPEN
|
||||
|
||||
set_node_attribute(window_covering, 1, 258, 8, 50)
|
||||
set_node_attribute(window_covering, 1, 258, 10, 1)
|
||||
@@ -137,5 +137,5 @@ async def test_cover(
|
||||
|
||||
state = hass.states.get("cover.longan_link_wncv_da01")
|
||||
assert state
|
||||
assert state.attributes["current_position"] == 100
|
||||
assert state.state == STATE_OPEN
|
||||
assert state.attributes["current_position"] == 0
|
||||
assert state.state == STATE_CLOSED
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user