Compare commits
194 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb586c7144 | |||
| ec15a03706 | |||
| 24b851c184 | |||
| a8539b89e8 | |||
| 8cf1ed81a8 | |||
| fe452452e6 | |||
| c632d27197 | |||
| 6a6eba1ca3 | |||
| a5241b3118 | |||
| 3bab40753d | |||
| 546c68196e | |||
| 379db033af | |||
| 4b9355e1ca | |||
| 89eca22b93 | |||
| 2cb665a1d9 | |||
| 1d54a0ed3d | |||
| 7af1521812 | |||
| c8cc6bfbb7 | |||
| 401e61588c | |||
| 3f948da2af | |||
| aafbc64e02 | |||
| e460bc7ecb | |||
| 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
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ PLATFORMS = [
|
||||
Platform.DEVICE_TRACKER,
|
||||
Platform.LOCK,
|
||||
Platform.NOTIFY,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer_connected==0.13.0"]
|
||||
"requirements": ["bimmer_connected==0.13.2"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Number platform for BMW."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from bimmer_connected.models import MyBMWAPIError
|
||||
from bimmer_connected.vehicle import MyBMWVehicle
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
NumberMode,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import BMWBaseEntity
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BMWDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BMWRequiredKeysMixin:
|
||||
"""Mixin for required keys."""
|
||||
|
||||
value_fn: Callable[[MyBMWVehicle], float | int | None]
|
||||
remote_service: Callable[[MyBMWVehicle, float | int], Coroutine[Any, Any, Any]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin):
|
||||
"""Describes BMW number entity."""
|
||||
|
||||
is_available: Callable[[MyBMWVehicle], bool] = lambda _: False
|
||||
dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None
|
||||
mode: NumberMode = NumberMode.AUTO
|
||||
|
||||
|
||||
NUMBER_TYPES: list[BMWNumberEntityDescription] = [
|
||||
BMWNumberEntityDescription(
|
||||
key="target_soc",
|
||||
name="Target SoC",
|
||||
device_class=NumberDeviceClass.BATTERY,
|
||||
is_available=lambda v: v.is_remote_set_target_soc_enabled,
|
||||
native_max_value=100.0,
|
||||
native_min_value=20.0,
|
||||
native_step=5.0,
|
||||
mode=NumberMode.SLIDER,
|
||||
value_fn=lambda v: v.fuel_and_battery.charging_target,
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
target_soc=int(o)
|
||||
),
|
||||
icon="mdi:battery-charging-medium",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the MyBMW number from config entry."""
|
||||
coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
|
||||
entities: list[BMWNumber] = []
|
||||
|
||||
for vehicle in coordinator.account.vehicles:
|
||||
if not coordinator.read_only:
|
||||
entities.extend(
|
||||
[
|
||||
BMWNumber(coordinator, vehicle, description)
|
||||
for description in NUMBER_TYPES
|
||||
if description.is_available(vehicle)
|
||||
]
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class BMWNumber(BMWBaseEntity, NumberEntity):
|
||||
"""Representation of BMW Number entity."""
|
||||
|
||||
entity_description: BMWNumberEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BMWDataUpdateCoordinator,
|
||||
vehicle: MyBMWVehicle,
|
||||
description: BMWNumberEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize an BMW Number."""
|
||||
super().__init__(coordinator, vehicle)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{vehicle.vin}-{description.key}"
|
||||
self._attr_mode = description.mode
|
||||
|
||||
@property
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the entity value to represent the entity state."""
|
||||
return self.entity_description.value_fn(self.vehicle)
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update to the vehicle."""
|
||||
_LOGGER.debug(
|
||||
"Executing '%s' on vehicle '%s' to value '%s'",
|
||||
self.entity_description.key,
|
||||
self.vehicle.vin,
|
||||
value,
|
||||
)
|
||||
try:
|
||||
await self.entity_description.remote_service(self.vehicle, value)
|
||||
except MyBMWAPIError as ex:
|
||||
raise HomeAssistantError(ex) from ex
|
||||
@@ -9,7 +9,7 @@ from bimmer_connected.vehicle.charging_profile import ChargingMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, UnitOfElectricCurrent
|
||||
from homeassistant.const import UnitOfElectricCurrent
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
@@ -37,19 +37,6 @@ class BMWSelectEntityDescription(SelectEntityDescription, BMWRequiredKeysMixin):
|
||||
|
||||
|
||||
SELECT_TYPES: dict[str, BMWSelectEntityDescription] = {
|
||||
# --- Generic ---
|
||||
"target_soc": BMWSelectEntityDescription(
|
||||
key="target_soc",
|
||||
name="Target SoC",
|
||||
is_available=lambda v: v.is_remote_set_target_soc_enabled,
|
||||
options=[str(i * 5 + 20) for i in range(17)],
|
||||
current_option=lambda v: str(v.fuel_and_battery.charging_target),
|
||||
remote_service=lambda v, o: v.remote_services.trigger_charging_settings_update(
|
||||
target_soc=int(o)
|
||||
),
|
||||
icon="mdi:battery-charging-medium",
|
||||
unit_of_measurement=PERCENTAGE,
|
||||
),
|
||||
"ac_limit": BMWSelectEntityDescription(
|
||||
key="ac_limit",
|
||||
name="AC Charging Limit",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,7 +164,7 @@ def _validate_rrule(value: Any) -> str:
|
||||
try:
|
||||
rrulestr(value)
|
||||
except ValueError as err:
|
||||
raise vol.Invalid(f"Invalid rrule: {str(err)}") from err
|
||||
raise vol.Invalid(f"Invalid rrule '{value}': {err}") from err
|
||||
|
||||
# Example format: FREQ=DAILY;UNTIL=...
|
||||
rule_parts = dict(s.split("=", 1) for s in value.split(";"))
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/environment_canada",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["env_canada"],
|
||||
"requirements": ["env_canada==0.5.33"]
|
||||
"requirements": ["env_canada==0.5.34"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -236,11 +236,18 @@ class SensorFilter(SensorEntity):
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if new_state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE):
|
||||
self._state = new_state.state
|
||||
if new_state.state == STATE_UNKNOWN:
|
||||
self._state = None
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
if new_state.state == STATE_UNAVAILABLE:
|
||||
self._attr_available = False
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
self._attr_available = True
|
||||
|
||||
temp_state = _State(new_state.last_updated, new_state.state)
|
||||
|
||||
try:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -80,7 +80,10 @@ class FritzBoxBinarySensor(FritzBoxBaseCoordinatorEntity, BinarySensorEntity):
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
if isinstance(
|
||||
state := self.coordinator.data.get(self.entity_description.key), bool
|
||||
state := self.coordinator.data["entity_states"].get(
|
||||
self.entity_description.key
|
||||
),
|
||||
bool,
|
||||
):
|
||||
return state
|
||||
return None
|
||||
|
||||
@@ -19,6 +19,7 @@ from fritzconnection.core.exceptions import (
|
||||
from fritzconnection.lib.fritzhosts import FritzHosts
|
||||
from fritzconnection.lib.fritzstatus import FritzStatus
|
||||
from fritzconnection.lib.fritzwlan import DEFAULT_PASSWORD_LENGTH, FritzGuestWLAN
|
||||
import xmltodict
|
||||
|
||||
from homeassistant.components.device_tracker import (
|
||||
CONF_CONSIDER_HOME,
|
||||
@@ -137,8 +138,15 @@ class HostInfo(TypedDict):
|
||||
status: bool
|
||||
|
||||
|
||||
class UpdateCoordinatorDataType(TypedDict):
|
||||
"""Update coordinator data type."""
|
||||
|
||||
call_deflections: dict[int, dict]
|
||||
entity_states: dict[str, StateType | bool]
|
||||
|
||||
|
||||
class FritzBoxTools(
|
||||
update_coordinator.DataUpdateCoordinator[dict[str, bool | StateType]]
|
||||
update_coordinator.DataUpdateCoordinator[UpdateCoordinatorDataType]
|
||||
):
|
||||
"""FritzBoxTools class."""
|
||||
|
||||
@@ -173,6 +181,7 @@ class FritzBoxTools(
|
||||
self.password = password
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.has_call_deflections: bool = False
|
||||
self._model: str | None = None
|
||||
self._current_firmware: str | None = None
|
||||
self._latest_firmware: str | None = None
|
||||
@@ -243,6 +252,8 @@ class FritzBoxTools(
|
||||
)
|
||||
self.device_is_router = self.fritz_status.has_wan_enabled
|
||||
|
||||
self.has_call_deflections = "X_AVM-DE_OnTel1" in self.connection.services
|
||||
|
||||
def register_entity_updates(
|
||||
self, key: str, update_fn: Callable[[FritzStatus, StateType], Any]
|
||||
) -> Callable[[], None]:
|
||||
@@ -259,20 +270,30 @@ class FritzBoxTools(
|
||||
self._entity_update_functions[key] = update_fn
|
||||
return unregister_entity_updates
|
||||
|
||||
async def _async_update_data(self) -> dict[str, bool | StateType]:
|
||||
async def _async_update_data(self) -> UpdateCoordinatorDataType:
|
||||
"""Update FritzboxTools data."""
|
||||
enity_data: dict[str, bool | StateType] = {}
|
||||
entity_data: UpdateCoordinatorDataType = {
|
||||
"call_deflections": {},
|
||||
"entity_states": {},
|
||||
}
|
||||
try:
|
||||
await self.async_scan_devices()
|
||||
for key, update_fn in self._entity_update_functions.items():
|
||||
_LOGGER.debug("update entity %s", key)
|
||||
enity_data[key] = await self.hass.async_add_executor_job(
|
||||
entity_data["entity_states"][
|
||||
key
|
||||
] = await self.hass.async_add_executor_job(
|
||||
update_fn, self.fritz_status, self.data.get(key)
|
||||
)
|
||||
if self.has_call_deflections:
|
||||
entity_data[
|
||||
"call_deflections"
|
||||
] = await self.async_update_call_deflections()
|
||||
except FRITZ_EXCEPTIONS as ex:
|
||||
raise update_coordinator.UpdateFailed(ex) from ex
|
||||
_LOGGER.debug("enity_data: %s", enity_data)
|
||||
return enity_data
|
||||
|
||||
_LOGGER.debug("enity_data: %s", entity_data)
|
||||
return entity_data
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
@@ -354,6 +375,23 @@ class FritzBoxTools(
|
||||
"""Retrieve latest device information from the FRITZ!Box."""
|
||||
return await self.hass.async_add_executor_job(self._update_device_info)
|
||||
|
||||
async def async_update_call_deflections(
|
||||
self,
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
"""Call GetDeflections action from X_AVM-DE_OnTel service."""
|
||||
raw_data = await self.hass.async_add_executor_job(
|
||||
partial(self.connection.call_action, "X_AVM-DE_OnTel1", "GetDeflections")
|
||||
)
|
||||
if not raw_data:
|
||||
return {}
|
||||
|
||||
xml_data = xmltodict.parse(raw_data["NewDeflectionList"])
|
||||
if xml_data.get("List") and (items := xml_data["List"].get("Item")) is not None:
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
return {int(item["DeflectionId"]): item for item in items}
|
||||
return {}
|
||||
|
||||
async def _async_get_wan_access(self, ip_address: str) -> bool | None:
|
||||
"""Get WAN access rule for given IP address."""
|
||||
try:
|
||||
@@ -772,18 +810,6 @@ class AvmWrapper(FritzBoxTools):
|
||||
"WLANConfiguration", str(index), "GetInfo"
|
||||
)
|
||||
|
||||
async def async_get_ontel_num_deflections(self) -> dict[str, Any]:
|
||||
"""Call GetNumberOfDeflections action from X_AVM-DE_OnTel service."""
|
||||
|
||||
return await self._async_service_call(
|
||||
"X_AVM-DE_OnTel", "1", "GetNumberOfDeflections"
|
||||
)
|
||||
|
||||
async def async_get_ontel_deflections(self) -> dict[str, Any]:
|
||||
"""Call GetDeflections action from X_AVM-DE_OnTel service."""
|
||||
|
||||
return await self._async_service_call("X_AVM-DE_OnTel", "1", "GetDeflections")
|
||||
|
||||
async def async_set_wlan_configuration(
|
||||
self, index: int, turn_on: bool
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -309,4 +309,4 @@ class FritzBoxSensor(FritzBoxBaseCoordinatorEntity, SensorEntity):
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the value reported by the sensor."""
|
||||
return self.coordinator.data.get(self.entity_description.key)
|
||||
return self.coordinator.data["entity_states"].get(self.entity_description.key)
|
||||
|
||||
@@ -4,10 +4,8 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import xmltodict
|
||||
|
||||
from homeassistant.components.network import async_get_source_ip
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -15,6 +13,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from .common import (
|
||||
@@ -47,31 +46,15 @@ async def _async_deflection_entities_list(
|
||||
|
||||
_LOGGER.debug("Setting up %s switches", SWITCH_TYPE_DEFLECTION)
|
||||
|
||||
deflections_response = await avm_wrapper.async_get_ontel_num_deflections()
|
||||
if not deflections_response:
|
||||
if (
|
||||
call_deflections := avm_wrapper.data.get("call_deflections")
|
||||
) is None or not isinstance(call_deflections, dict):
|
||||
_LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION)
|
||||
return []
|
||||
|
||||
_LOGGER.debug(
|
||||
"Specific %s response: GetNumberOfDeflections=%s",
|
||||
SWITCH_TYPE_DEFLECTION,
|
||||
deflections_response,
|
||||
)
|
||||
|
||||
if deflections_response["NewNumberOfDeflections"] == 0:
|
||||
_LOGGER.debug("The FRITZ!Box has no %s options", SWITCH_TYPE_DEFLECTION)
|
||||
return []
|
||||
|
||||
if not (deflection_list := await avm_wrapper.async_get_ontel_deflections()):
|
||||
return []
|
||||
|
||||
items = xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"]
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
|
||||
return [
|
||||
FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, dict_of_deflection)
|
||||
for dict_of_deflection in items
|
||||
FritzBoxDeflectionSwitch(avm_wrapper, device_friendly_name, cd_id)
|
||||
for cd_id in call_deflections
|
||||
]
|
||||
|
||||
|
||||
@@ -273,6 +256,61 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity, SwitchEntity):
|
||||
"""Fritz switch coordinator base class."""
|
||||
|
||||
coordinator: AvmWrapper
|
||||
entity_description: SwitchEntityDescription
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_name: str,
|
||||
description: SwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Init device info class."""
|
||||
super().__init__(avm_wrapper)
|
||||
self.entity_description = description
|
||||
self._device_name = device_name
|
||||
self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Return the device information."""
|
||||
return DeviceInfo(
|
||||
configuration_url=f"http://{self.coordinator.host}",
|
||||
connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)},
|
||||
identifiers={(DOMAIN, self.coordinator.unique_id)},
|
||||
manufacturer="AVM",
|
||||
model=self.coordinator.model,
|
||||
name=self._device_name,
|
||||
sw_version=self.coordinator.current_firmware,
|
||||
)
|
||||
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return entity data from coordinator data."""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return availability based on data availability."""
|
||||
return super().available and bool(self.data)
|
||||
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
"""Handle switch state change request."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off switch."""
|
||||
await self._async_handle_turn_on_off(turn_on=False)
|
||||
|
||||
|
||||
class FritzBoxBaseSwitch(FritzBoxBaseEntity):
|
||||
"""Fritz switch base class."""
|
||||
|
||||
@@ -417,69 +455,51 @@ class FritzBoxPortSwitch(FritzBoxBaseSwitch, SwitchEntity):
|
||||
return bool(resp is not None)
|
||||
|
||||
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseSwitch, SwitchEntity):
|
||||
class FritzBoxDeflectionSwitch(FritzBoxBaseCoordinatorSwitch):
|
||||
"""Defines a FRITZ!Box Tools PortForward switch."""
|
||||
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
avm_wrapper: AvmWrapper,
|
||||
device_friendly_name: str,
|
||||
dict_of_deflection: Any,
|
||||
deflection_id: int,
|
||||
) -> None:
|
||||
"""Init Fritxbox Deflection class."""
|
||||
self._avm_wrapper = avm_wrapper
|
||||
|
||||
self.dict_of_deflection = dict_of_deflection
|
||||
self._attributes = {}
|
||||
self.id = int(self.dict_of_deflection["DeflectionId"])
|
||||
self._attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
switch_info = SwitchInfo(
|
||||
description=f"Call deflection {self.id}",
|
||||
friendly_name=device_friendly_name,
|
||||
self.deflection_id = deflection_id
|
||||
description = SwitchEntityDescription(
|
||||
key=f"call_deflection_{self.deflection_id}",
|
||||
name=f"Call deflection {self.deflection_id}",
|
||||
icon="mdi:phone-forward",
|
||||
type=SWITCH_TYPE_DEFLECTION,
|
||||
callback_update=self._async_fetch_update,
|
||||
callback_switch=self._async_switch_on_off_executor,
|
||||
)
|
||||
super().__init__(self._avm_wrapper, device_friendly_name, switch_info)
|
||||
super().__init__(avm_wrapper, device_friendly_name, description)
|
||||
|
||||
async def _async_fetch_update(self) -> None:
|
||||
"""Fetch updates."""
|
||||
@property
|
||||
def data(self) -> dict[str, Any]:
|
||||
"""Return call deflection data."""
|
||||
return self.coordinator.data["call_deflections"].get(self.deflection_id, {})
|
||||
|
||||
resp = await self._avm_wrapper.async_get_ontel_deflections()
|
||||
if not resp:
|
||||
self._is_available = False
|
||||
return
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, str]:
|
||||
"""Return device attributes."""
|
||||
return {
|
||||
"type": self.data["Type"],
|
||||
"number": self.data["Number"],
|
||||
"deflection_to_number": self.data["DeflectionToNumber"],
|
||||
"mode": self.data["Mode"][1:],
|
||||
"outgoing": self.data["Outgoing"],
|
||||
"phonebook_id": self.data["PhonebookID"],
|
||||
}
|
||||
|
||||
self.dict_of_deflection = xmltodict.parse(resp["NewDeflectionList"])["List"][
|
||||
"Item"
|
||||
]
|
||||
if isinstance(self.dict_of_deflection, list):
|
||||
self.dict_of_deflection = self.dict_of_deflection[self.id]
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Switch status."""
|
||||
return self.data.get("Enable") == "1"
|
||||
|
||||
_LOGGER.debug(
|
||||
"Specific %s response: NewDeflectionList=%s",
|
||||
SWITCH_TYPE_DEFLECTION,
|
||||
self.dict_of_deflection,
|
||||
)
|
||||
|
||||
self._attr_is_on = self.dict_of_deflection["Enable"] == "1"
|
||||
self._is_available = True
|
||||
|
||||
self._attributes["type"] = self.dict_of_deflection["Type"]
|
||||
self._attributes["number"] = self.dict_of_deflection["Number"]
|
||||
self._attributes["deflection_to_number"] = self.dict_of_deflection[
|
||||
"DeflectionToNumber"
|
||||
]
|
||||
# Return mode sample: "eImmediately"
|
||||
self._attributes["mode"] = self.dict_of_deflection["Mode"][1:]
|
||||
self._attributes["outgoing"] = self.dict_of_deflection["Outgoing"]
|
||||
self._attributes["phonebook_id"] = self.dict_of_deflection["PhonebookID"]
|
||||
|
||||
async def _async_switch_on_off_executor(self, turn_on: bool) -> None:
|
||||
async def _async_handle_turn_on_off(self, turn_on: bool) -> None:
|
||||
"""Handle deflection switch."""
|
||||
await self._avm_wrapper.async_set_deflection_enable(self.id, turn_on)
|
||||
await self.coordinator.async_set_deflection_enable(self.deflection_id, turn_on)
|
||||
|
||||
|
||||
class FritzBoxProfileSwitch(FritzDeviceBase, SwitchEntity):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -43,10 +43,10 @@ class GoogleMailSensor(GoogleMailEntity, SensorEntity):
|
||||
"""Get the vacation data."""
|
||||
service = await self.auth.get_resource()
|
||||
settings: HttpRequest = service.users().settings().getVacation(userId="me")
|
||||
data = await self.hass.async_add_executor_job(settings.execute)
|
||||
data: dict = await self.hass.async_add_executor_job(settings.execute)
|
||||
|
||||
if data["enableAutoReply"]:
|
||||
value = datetime.fromtimestamp(int(data["endTime"]) / 1000, tz=timezone.utc)
|
||||
if data["enableAutoReply"] and (end := data.get("endTime")):
|
||||
value = datetime.fromtimestamp(int(end) / 1000, tz=timezone.utc)
|
||||
else:
|
||||
value = None
|
||||
self._attr_native_value = value
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from homeassistant.components import frontend
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.recorder import get_instance, history
|
||||
from homeassistant.components.recorder.util import session_scope
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE
|
||||
from homeassistant.core import HomeAssistant, valid_entity_id
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA
|
||||
@@ -27,16 +28,16 @@ CONF_ORDER = "use_include_order"
|
||||
_ONE_DAY = timedelta(days=1)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
vol.All(
|
||||
cv.deprecated(DOMAIN),
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend(
|
||||
{vol.Optional(CONF_ORDER, default=False): cv.boolean}
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
{
|
||||
DOMAIN: vol.All(
|
||||
cv.deprecated(CONF_INCLUDE),
|
||||
cv.deprecated(CONF_EXCLUDE),
|
||||
cv.deprecated(CONF_ORDER),
|
||||
INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA.extend(
|
||||
{vol.Optional(CONF_ORDER, default=False): cv.boolean}
|
||||
),
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"loggers": ["pyinsteon", "pypubsub"],
|
||||
"requirements": [
|
||||
"pyinsteon==1.4.2",
|
||||
"insteon-frontend-home-assistant==0.3.4"
|
||||
"insteon-frontend-home-assistant==0.3.5"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
||||
@@ -194,6 +194,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
conf = dict(entry.data)
|
||||
hass_config = await conf_util.async_hass_config_yaml(hass)
|
||||
mqtt_yaml = PLATFORM_CONFIG_SCHEMA_BASE(hass_config.get(DOMAIN, {}))
|
||||
await async_create_certificate_temp_files(hass, conf)
|
||||
client = MQTT(hass, entry, conf)
|
||||
if DOMAIN in hass.data:
|
||||
mqtt_data = get_mqtt_data(hass)
|
||||
@@ -206,7 +207,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DATA_MQTT] = mqtt_data = MqttData(config=mqtt_yaml, client=client)
|
||||
client.start(mqtt_data)
|
||||
|
||||
await async_create_certificate_temp_files(hass, dict(entry.data))
|
||||
# Restore saved subscriptions
|
||||
if mqtt_data.subscriptions_to_restore:
|
||||
mqtt_data.client.async_restore_tracked_subscriptions(
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +316,15 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# Verify there is an H264 profile
|
||||
media_service = device.create_media_service()
|
||||
profiles = await media_service.GetProfiles()
|
||||
except AttributeError: # Likely an empty document or 404 from the wrong port
|
||||
LOGGER.debug(
|
||||
"%s: No ONVIF service found at %s:%s",
|
||||
self.onvif_config[CONF_NAME],
|
||||
self.onvif_config[CONF_HOST],
|
||||
self.onvif_config[CONF_PORT],
|
||||
exc_info=True,
|
||||
)
|
||||
return {CONF_PORT: "no_onvif_service"}, {}
|
||||
except Fault as err:
|
||||
stringified_error = stringify_onvif_error(err)
|
||||
description_placeholders = {"error": stringified_error}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"error": {
|
||||
"onvif_error": "Error setting up ONVIF device: {error}. Check logs for more information.",
|
||||
"auth_failed": "Could not authenticate: {error}",
|
||||
"no_onvif_service": "No ONVIF service found. Check that the port number is correct.",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
|
||||
@@ -18,7 +18,12 @@ def stringify_onvif_error(error: Exception) -> str:
|
||||
if isinstance(error, Fault):
|
||||
message = error.message
|
||||
if error.detail:
|
||||
message += ": " + error.detail
|
||||
# Detail may be a bytes object, so we need to convert it to string
|
||||
if isinstance(error.detail, bytes):
|
||||
detail = error.detail.decode("utf-8", "replace")
|
||||
else:
|
||||
detail = str(error.detail)
|
||||
message += ": " + detail
|
||||
if error.code:
|
||||
message += f" (code:{error.code})"
|
||||
if error.subcodes:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
from typing import Any
|
||||
|
||||
from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState
|
||||
from pyoverkiz.enums.ui import UIClass, UIWidget
|
||||
@@ -15,12 +15,12 @@ from homeassistant.components.switch import (
|
||||
SwitchEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.const import EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import HomeAssistantOverkizData
|
||||
from .const import DOMAIN, IGNORED_OVERKIZ_DEVICES
|
||||
from .const import DOMAIN
|
||||
from .entity import OverkizDescriptiveEntity
|
||||
|
||||
|
||||
@@ -107,19 +107,6 @@ SWITCH_DESCRIPTIONS: list[OverkizSwitchDescription] = [
|
||||
),
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
OverkizSwitchDescription(
|
||||
key=UIWidget.DYNAMIC_SHUTTER,
|
||||
name="Silent mode",
|
||||
turn_on=OverkizCommand.ACTIVATE_OPTION,
|
||||
turn_on_args=OverkizCommandParam.SILENCE,
|
||||
turn_off=OverkizCommand.DEACTIVATE_OPTION,
|
||||
turn_off_args=OverkizCommandParam.SILENCE,
|
||||
is_on=lambda select_state: (
|
||||
OverkizCommandParam.SILENCE
|
||||
in cast(list, select_state(OverkizState.CORE_ACTIVATED_OPTIONS))
|
||||
),
|
||||
icon="mdi:feather",
|
||||
),
|
||||
]
|
||||
|
||||
SUPPORTED_DEVICES = {
|
||||
@@ -136,13 +123,7 @@ async def async_setup_entry(
|
||||
data: HomeAssistantOverkizData = hass.data[DOMAIN][entry.entry_id]
|
||||
entities: list[OverkizSwitch] = []
|
||||
|
||||
for device in data.coordinator.data.values():
|
||||
if (
|
||||
device.widget in IGNORED_OVERKIZ_DEVICES
|
||||
or device.ui_class in IGNORED_OVERKIZ_DEVICES
|
||||
):
|
||||
continue
|
||||
|
||||
for device in data.platforms[Platform.SWITCH]:
|
||||
if description := SUPPORTED_DEVICES.get(device.widget) or SUPPORTED_DEVICES.get(
|
||||
device.ui_class
|
||||
):
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["crcmod", "plugwise"],
|
||||
"requirements": ["plugwise==0.31.0"],
|
||||
"requirements": ["plugwise==0.31.1"],
|
||||
"zeroconf": ["_plugwise._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..schema import (
|
||||
correct_db_schema_precision,
|
||||
correct_db_schema_utf8,
|
||||
validate_db_schema_precision,
|
||||
validate_table_schema_has_correct_collation,
|
||||
validate_table_schema_supports_utf8,
|
||||
)
|
||||
|
||||
@@ -17,9 +18,12 @@ if TYPE_CHECKING:
|
||||
|
||||
def validate_db_schema(instance: Recorder) -> set[str]:
|
||||
"""Do some basic checks for common schema errors caused by manual migration."""
|
||||
return validate_table_schema_supports_utf8(
|
||||
schema_errors = validate_table_schema_supports_utf8(
|
||||
instance, EventData, (EventData.shared_data,)
|
||||
) | validate_db_schema_precision(instance, Events)
|
||||
for table in (Events, EventData):
|
||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||
return schema_errors
|
||||
|
||||
|
||||
def correct_db_schema(
|
||||
@@ -27,5 +31,6 @@ def correct_db_schema(
|
||||
schema_errors: set[str],
|
||||
) -> None:
|
||||
"""Correct issues detected by validate_db_schema."""
|
||||
correct_db_schema_utf8(instance, EventData, schema_errors)
|
||||
for table in (Events, EventData):
|
||||
correct_db_schema_utf8(instance, table, schema_errors)
|
||||
correct_db_schema_precision(instance, Events, schema_errors)
|
||||
|
||||
@@ -5,6 +5,7 @@ from collections.abc import Iterable, Mapping
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
@@ -60,6 +61,60 @@ def validate_table_schema_supports_utf8(
|
||||
return schema_errors
|
||||
|
||||
|
||||
def validate_table_schema_has_correct_collation(
|
||||
instance: Recorder,
|
||||
table_object: type[DeclarativeBase],
|
||||
) -> set[str]:
|
||||
"""Verify the table has the correct collation."""
|
||||
schema_errors: set[str] = set()
|
||||
# Lack of full utf8 support is only an issue for MySQL / MariaDB
|
||||
if instance.dialect_name != SupportedDialect.MYSQL:
|
||||
return schema_errors
|
||||
|
||||
try:
|
||||
schema_errors = _validate_table_schema_has_correct_collation(
|
||||
instance, table_object
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Error when validating DB schema: %s", exc)
|
||||
|
||||
_log_schema_errors(table_object, schema_errors)
|
||||
return schema_errors
|
||||
|
||||
|
||||
def _validate_table_schema_has_correct_collation(
|
||||
instance: Recorder,
|
||||
table_object: type[DeclarativeBase],
|
||||
) -> set[str]:
|
||||
"""Ensure the table has the correct collation to avoid union errors with mixed collations."""
|
||||
schema_errors: set[str] = set()
|
||||
# Mark the session as read_only to ensure that the test data is not committed
|
||||
# to the database and we always rollback when the scope is exited
|
||||
with session_scope(session=instance.get_session(), read_only=True) as session:
|
||||
table = table_object.__tablename__
|
||||
metadata_obj = MetaData()
|
||||
connection = session.connection()
|
||||
metadata_obj.reflect(bind=connection)
|
||||
dialect_kwargs = metadata_obj.tables[table].dialect_kwargs
|
||||
# Check if the table has a collation set, if its not set than its
|
||||
# using the server default collation for the database
|
||||
|
||||
collate = (
|
||||
dialect_kwargs.get("mysql_collate")
|
||||
or dialect_kwargs.get(
|
||||
"mariadb_collate"
|
||||
) # pylint: disable-next=protected-access
|
||||
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined]
|
||||
)
|
||||
if collate and collate != "utf8mb4_unicode_ci":
|
||||
_LOGGER.debug(
|
||||
"Database %s collation is not utf8mb4_unicode_ci",
|
||||
table,
|
||||
)
|
||||
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
|
||||
return schema_errors
|
||||
|
||||
|
||||
def _validate_table_schema_supports_utf8(
|
||||
instance: Recorder,
|
||||
table_object: type[DeclarativeBase],
|
||||
@@ -184,7 +239,10 @@ def correct_db_schema_utf8(
|
||||
) -> None:
|
||||
"""Correct utf8 issues detected by validate_db_schema."""
|
||||
table_name = table_object.__tablename__
|
||||
if f"{table_name}.4-byte UTF-8" in schema_errors:
|
||||
if (
|
||||
f"{table_name}.4-byte UTF-8" in schema_errors
|
||||
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
|
||||
):
|
||||
from ..migration import ( # pylint: disable=import-outside-toplevel
|
||||
_correct_table_character_set_and_collation,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..schema import (
|
||||
correct_db_schema_precision,
|
||||
correct_db_schema_utf8,
|
||||
validate_db_schema_precision,
|
||||
validate_table_schema_has_correct_collation,
|
||||
validate_table_schema_supports_utf8,
|
||||
)
|
||||
|
||||
@@ -26,6 +27,8 @@ def validate_db_schema(instance: Recorder) -> set[str]:
|
||||
for table, columns in TABLE_UTF8_COLUMNS.items():
|
||||
schema_errors |= validate_table_schema_supports_utf8(instance, table, columns)
|
||||
schema_errors |= validate_db_schema_precision(instance, States)
|
||||
for table in (States, StateAttributes):
|
||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||
return schema_errors
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from ..schema import (
|
||||
correct_db_schema_precision,
|
||||
correct_db_schema_utf8,
|
||||
validate_db_schema_precision,
|
||||
validate_table_schema_has_correct_collation,
|
||||
validate_table_schema_supports_utf8,
|
||||
)
|
||||
|
||||
@@ -26,6 +27,7 @@ def validate_db_schema(instance: Recorder) -> set[str]:
|
||||
)
|
||||
for table in (Statistics, StatisticsShortTerm):
|
||||
schema_errors |= validate_db_schema_precision(instance, table)
|
||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||
if schema_errors:
|
||||
_LOGGER.debug(
|
||||
"Detected statistics schema errors: %s", ", ".join(sorted(schema_errors))
|
||||
@@ -41,3 +43,4 @@ def correct_db_schema(
|
||||
correct_db_schema_utf8(instance, StatisticsMeta, schema_errors)
|
||||
for table in (Statistics, StatisticsShortTerm):
|
||||
correct_db_schema_precision(instance, table, schema_errors)
|
||||
correct_db_schema_utf8(instance, table, schema_errors)
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/solaredge_local",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["solaredge_local"],
|
||||
"requirements": ["solaredge-local==0.2.0"]
|
||||
"requirements": ["solaredge-local==0.2.3"]
|
||||
}
|
||||
|
||||
@@ -290,7 +290,7 @@ class SolarEdgeSensor(SensorEntity):
|
||||
"""Return the state attributes."""
|
||||
if extra_attr := self.entity_description.extra_attribute:
|
||||
try:
|
||||
return {extra_attr: self._data.info[self.entity_description.key]}
|
||||
return {extra_attr: self._data.info.get(self.entity_description.key)}
|
||||
except KeyError:
|
||||
pass
|
||||
return None
|
||||
@@ -298,7 +298,7 @@ class SolarEdgeSensor(SensorEntity):
|
||||
def update(self) -> None:
|
||||
"""Get the latest data from the sensor and update the state."""
|
||||
self._data.update()
|
||||
self._attr_native_value = self._data.data[self.entity_description.key]
|
||||
self._attr_native_value = self._data.data.get(self.entity_description.key)
|
||||
|
||||
|
||||
class SolarEdgeData:
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["soco"],
|
||||
"requirements": ["soco==0.29.1", "sonos-websocket==0.0.5"],
|
||||
"requirements": ["soco==0.29.1", "sonos-websocket==0.1.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyunifiprotect", "unifi_discovery"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyunifiprotect==4.8.2", "unifi-discovery==1.1.7"],
|
||||
"requirements": ["pyunifiprotect==4.8.3", "unifi-discovery==1.1.7"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -161,8 +161,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) ->
|
||||
camera = instance.bootstrap.get_device_from_mac(doorbell_mac)
|
||||
assert camera is not None
|
||||
doorbell_ids.add(camera.id)
|
||||
data_before_changed = chime.dict_with_excludes()
|
||||
chime.camera_ids = sorted(doorbell_ids)
|
||||
await chime.save_device()
|
||||
await chime.save_device(data_before_changed)
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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": {
|
||||
|
||||
@@ -105,6 +105,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
buffered_chunks_before_speech: int = 100,
|
||||
listening_tone_enabled: bool = True,
|
||||
processing_tone_enabled: bool = True,
|
||||
error_tone_enabled: bool = True,
|
||||
tone_delay: float = 0.2,
|
||||
tts_extra_timeout: float = 1.0,
|
||||
) -> None:
|
||||
@@ -120,6 +121,7 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
self.buffered_chunks_before_speech = buffered_chunks_before_speech
|
||||
self.listening_tone_enabled = listening_tone_enabled
|
||||
self.processing_tone_enabled = processing_tone_enabled
|
||||
self.error_tone_enabled = error_tone_enabled
|
||||
self.tone_delay = tone_delay
|
||||
self.tts_extra_timeout = tts_extra_timeout
|
||||
|
||||
@@ -131,6 +133,8 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
self._session_id: str | None = None
|
||||
self._tone_bytes: bytes | None = None
|
||||
self._processing_bytes: bytes | None = None
|
||||
self._error_bytes: bytes | None = None
|
||||
self._pipeline_error: bool = False
|
||||
|
||||
def connection_made(self, transport):
|
||||
"""Server is ready."""
|
||||
@@ -161,8 +165,10 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
"""Forward audio to pipeline STT and handle TTS."""
|
||||
if self._session_id is None:
|
||||
self._session_id = ulid()
|
||||
if self.listening_tone_enabled:
|
||||
await self._play_listening_tone()
|
||||
|
||||
# Play listening tone at the start of each cycle
|
||||
if self.listening_tone_enabled:
|
||||
await self._play_listening_tone()
|
||||
|
||||
try:
|
||||
# Wait for speech before starting pipeline
|
||||
@@ -221,11 +227,16 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
tts_audio_output="raw",
|
||||
)
|
||||
|
||||
# Block until TTS is done speaking.
|
||||
#
|
||||
# This is set in _send_tts and has a timeout that's based on the
|
||||
# length of the TTS audio.
|
||||
await self._tts_done.wait()
|
||||
if self._pipeline_error:
|
||||
self._pipeline_error = False
|
||||
if self.error_tone_enabled:
|
||||
await self._play_error_tone()
|
||||
else:
|
||||
# Block until TTS is done speaking.
|
||||
#
|
||||
# This is set in _send_tts and has a timeout that's based on the
|
||||
# length of the TTS audio.
|
||||
await self._tts_done.wait()
|
||||
|
||||
_LOGGER.debug("Pipeline finished")
|
||||
except asyncio.TimeoutError:
|
||||
@@ -307,6 +318,9 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
self._send_tts(media_id),
|
||||
"voip_pipeline_tts",
|
||||
)
|
||||
elif event.type == PipelineEventType.ERROR:
|
||||
# Play error tone instead of wait for TTS
|
||||
self._pipeline_error = True
|
||||
|
||||
async def _send_tts(self, media_id: str) -> None:
|
||||
"""Send TTS audio to caller via RTP."""
|
||||
@@ -372,6 +386,23 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol):
|
||||
)
|
||||
)
|
||||
|
||||
async def _play_error_tone(self) -> None:
|
||||
"""Play a tone to indicate a pipeline error occurred."""
|
||||
if self._error_bytes is None:
|
||||
# Do I/O in executor
|
||||
self._error_bytes = await self.hass.async_add_executor_job(
|
||||
self._load_pcm,
|
||||
"error.pcm",
|
||||
)
|
||||
|
||||
await self.hass.async_add_executor_job(
|
||||
partial(
|
||||
self.send_audio,
|
||||
self._error_bytes,
|
||||
**RTP_AUDIO_SETTINGS,
|
||||
)
|
||||
)
|
||||
|
||||
def _load_pcm(self, file_name: str) -> bytes:
|
||||
"""Load raw audio (16Khz, 16-bit mono)."""
|
||||
return (Path(__file__).parent / file_name).read_bytes()
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user