Compare commits
245 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c61e29709c | |||
| 458fe17a48 | |||
| 15fdefd23b | |||
| 576f9600b5 | |||
| 7a62574360 | |||
| 0251d677d8 | |||
| 2cd9b94ecb | |||
| 3cd2ab2319 | |||
| 4f0d403393 | |||
| b558cf8b59 | |||
| 820c7b77ce | |||
| 9d0fc916fc | |||
| 387f07a97f | |||
| 44968cfc7c | |||
| c6751bed86 | |||
| b87e3860d9 | |||
| 8ef6bd85f5 | |||
| ad4fed4f60 | |||
| 1050895657 | |||
| c31d657206 | |||
| 88343bed77 | |||
| 51a10a84da | |||
| 5f3bbf2804 | |||
| b8eebf085c | |||
| cdfd53e1cc | |||
| ca147dd97e | |||
| 5b1278d885 | |||
| 0db28dcf4d | |||
| 7c651665c5 | |||
| 2f3964e3ce | |||
| eef95fa0d4 | |||
| 43a1eb043b | |||
| 6b77775ed5 | |||
| 7077d23127 | |||
| c7eac0ebbb | |||
| 7f13033f69 | |||
| eba201e71b | |||
| 1e9d777201 | |||
| 030b7f8a37 | |||
| 8cbc69fc92 | |||
| 2a5f5ea039 | |||
| 0ba662e7bc | |||
| 05530d656a | |||
| 2b2be6a333 | |||
| 5bd54490ea | |||
| 00a28caa6d | |||
| c4aa6ba262 | |||
| 7a90db903b | |||
| fe279c8593 | |||
| ddf5a9fbcc | |||
| 093d5d6176 | |||
| 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
|
||||
|
||||
@@ -60,6 +60,7 @@ class AlexaConfig(AbstractConfig):
|
||||
"""Return an identifier for the user that represents this config."""
|
||||
return ""
|
||||
|
||||
@core.callback
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if not self._config[CONF_FILTER].empty_filter:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from atenpdu import AtenPE, AtenPEError
|
||||
from atenpdu import AtenPE, AtenPEError # pylint: disable=import-error
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
|
||||
@@ -3,6 +3,7 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
from yalexs.util import get_latest_activity
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
@@ -169,12 +170,11 @@ class ActivityStream(AugustSubscriberMixin):
|
||||
device_id = activity.device_id
|
||||
activity_type = activity.activity_type
|
||||
device_activities = self._latest_activities.setdefault(device_id, {})
|
||||
lastest_activity = device_activities.get(activity_type)
|
||||
|
||||
# Ignore activities that are older than the latest one
|
||||
# Ignore activities that are older than the latest one unless it is a non
|
||||
# locking or unlocking activity with the exact same start time.
|
||||
if (
|
||||
lastest_activity
|
||||
and lastest_activity.activity_start_time >= activity.activity_start_time
|
||||
get_latest_activity(activity, device_activities.get(activity_type))
|
||||
!= activity
|
||||
):
|
||||
continue
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
from aiohttp import ClientResponseError
|
||||
from yalexs.activity import SOURCE_PUBNUB, ActivityType
|
||||
from yalexs.lock import LockStatus
|
||||
from yalexs.util import update_lock_detail_from_activity
|
||||
from yalexs.util import get_latest_activity, update_lock_detail_from_activity
|
||||
|
||||
from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -90,17 +90,26 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity):
|
||||
@callback
|
||||
def _update_from_data(self):
|
||||
"""Get the latest state of the sensor and update activity."""
|
||||
lock_activity = self._data.activity_stream.get_latest_device_activity(
|
||||
self._device_id,
|
||||
{ActivityType.LOCK_OPERATION, ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
|
||||
activity_stream = self._data.activity_stream
|
||||
device_id = self._device_id
|
||||
if lock_activity := activity_stream.get_latest_device_activity(
|
||||
device_id,
|
||||
{ActivityType.LOCK_OPERATION},
|
||||
):
|
||||
self._attr_changed_by = lock_activity.operated_by
|
||||
|
||||
lock_activity_without_operator = activity_stream.get_latest_device_activity(
|
||||
device_id,
|
||||
{ActivityType.LOCK_OPERATION_WITHOUT_OPERATOR},
|
||||
)
|
||||
|
||||
if lock_activity is not None:
|
||||
self._attr_changed_by = lock_activity.operated_by
|
||||
update_lock_detail_from_activity(self._detail, lock_activity)
|
||||
# If the source is pubnub the lock must be online since its a live update
|
||||
if lock_activity.source == SOURCE_PUBNUB:
|
||||
if latest_activity := get_latest_activity(
|
||||
lock_activity_without_operator, lock_activity
|
||||
):
|
||||
if latest_activity.source == SOURCE_PUBNUB:
|
||||
# If the source is pubnub the lock must be online since its a live update
|
||||
self._detail.set_online(True)
|
||||
update_lock_detail_from_activity(self._detail, latest_activity)
|
||||
|
||||
bridge_activity = self._data.activity_stream.get_latest_device_activity(
|
||||
self._device_id, {ActivityType.BRIDGE_OPERATION}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.3.2", "yalexs-ble==2.1.16"]
|
||||
"requirements": ["yalexs==1.3.3", "yalexs-ble==2.1.16"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"bleak==0.20.1",
|
||||
"bleak==0.20.2",
|
||||
"bleak-retry-connector==3.0.2",
|
||||
"bluetooth-adapters==0.15.3",
|
||||
"bluetooth-auto-recovery==1.0.3",
|
||||
"bluetooth-auto-recovery==1.1.1",
|
||||
"bluetooth-data-tools==0.4.0",
|
||||
"dbus-fast==1.85.0"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pybravia"],
|
||||
"requirements": ["pybravia==0.3.2"],
|
||||
"requirements": ["pybravia==0.3.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-sony-com:service:ScalarWebAPI:1",
|
||||
|
||||
@@ -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,20 @@ 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_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
async_get_entity_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.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, start
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.dt import utcnow
|
||||
@@ -51,6 +57,73 @@ 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
|
||||
|
||||
try:
|
||||
device_class = get_device_class(hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
# The entity no longer exists
|
||||
return False
|
||||
if (
|
||||
domain == "binary_sensor"
|
||||
and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES
|
||||
):
|
||||
return True
|
||||
|
||||
if domain == "sensor" and device_class in SUPPORTED_SENSOR_DEVICE_CLASSES:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
"""Alexa Configuration."""
|
||||
|
||||
@@ -127,35 +200,52 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
# Don't migrate if there's a YAML config
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
for entity_id, entry in entity_registry.entities.items():
|
||||
if CLOUD_ALEXA in entry.options:
|
||||
continue
|
||||
options = {"should_expose": self._should_expose_legacy(entity_id)}
|
||||
entity_registry.async_update_entity_options(entity_id, CLOUD_ALEXA, options)
|
||||
for state in self.hass.states.async_all():
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, state.entity_id)
|
||||
if CLOUD_ALEXA in entity_settings:
|
||||
continue
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_ALEXA,
|
||||
state.entity_id,
|
||||
self._should_expose_legacy(state.entity_id),
|
||||
)
|
||||
for entity_id in self._prefs.alexa_entity_configs:
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, entity_id)
|
||||
if CLOUD_ALEXA in entity_settings:
|
||||
continue
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_ALEXA,
|
||||
entity_id,
|
||||
self._should_expose_legacy(entity_id),
|
||||
)
|
||||
|
||||
async def async_initialize(self):
|
||||
"""Initialize the Alexa config."""
|
||||
await super().async_initialize()
|
||||
|
||||
if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
|
||||
if self._prefs.alexa_settings_version < 2:
|
||||
self._migrate_alexa_entity_settings_v1()
|
||||
await self._prefs.async_update(
|
||||
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
||||
async def on_hass_started(hass):
|
||||
if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
|
||||
if self._prefs.alexa_settings_version < 2:
|
||||
self._migrate_alexa_entity_settings_v1()
|
||||
await self._prefs.async_update(
|
||||
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
||||
)
|
||||
async_listen_entity_updates(
|
||||
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
|
||||
)
|
||||
|
||||
async def hass_started(hass):
|
||||
async def on_hass_start(hass):
|
||||
if self.enabled and ALEXA_DOMAIN not in self.hass.config.components:
|
||||
await async_setup_component(self.hass, ALEXA_DOMAIN, {})
|
||||
|
||||
start.async_at_start(self.hass, hass_started)
|
||||
start.async_at_start(self.hass, on_hass_start)
|
||||
start.async_at_started(self.hass, on_hass_started)
|
||||
|
||||
self._prefs.async_listen_updates(self._async_prefs_updated)
|
||||
async_listen_entity_updates(
|
||||
self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
self._handle_entity_registry_updated,
|
||||
@@ -183,10 +273,15 @@ 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)
|
||||
)
|
||||
|
||||
@callback
|
||||
def should_expose(self, entity_id):
|
||||
"""If an entity should be exposed."""
|
||||
if not self._config[CONF_FILTER].empty_filter:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Google config for Cloud."""
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -7,12 +8,17 @@ 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_expose_entity,
|
||||
async_get_entity_settings,
|
||||
async_listen_entity_updates,
|
||||
async_set_assistant_option,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import (
|
||||
CoreState,
|
||||
@@ -21,7 +27,9 @@ from homeassistant.core import (
|
||||
callback,
|
||||
split_entity_id,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er, start
|
||||
from homeassistant.helpers.entity import get_device_class
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .const import (
|
||||
@@ -39,6 +47,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."""
|
||||
|
||||
@@ -101,34 +176,67 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
# Don't migrate if there's a YAML config
|
||||
return
|
||||
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
for entity_id, entry in entity_registry.entities.items():
|
||||
if CLOUD_GOOGLE in entry.options:
|
||||
continue
|
||||
options = {"should_expose": self._should_expose_legacy(entity_id)}
|
||||
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
|
||||
options[PREF_DISABLE_2FA] = _2fa_disabled
|
||||
entity_registry.async_update_entity_options(
|
||||
entity_id, CLOUD_GOOGLE, options
|
||||
for state in self.hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, entity_id)
|
||||
if CLOUD_GOOGLE in entity_settings:
|
||||
continue
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
self._should_expose_legacy(entity_id),
|
||||
)
|
||||
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
|
||||
async_set_assistant_option(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
PREF_DISABLE_2FA,
|
||||
_2fa_disabled,
|
||||
)
|
||||
for entity_id in self._prefs.google_entity_configs:
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, entity_id)
|
||||
if CLOUD_GOOGLE in entity_settings:
|
||||
continue
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
self._should_expose_legacy(entity_id),
|
||||
)
|
||||
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
|
||||
async_set_assistant_option(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
entity_id,
|
||||
PREF_DISABLE_2FA,
|
||||
_2fa_disabled,
|
||||
)
|
||||
|
||||
async def async_initialize(self):
|
||||
"""Perform async initialization of config."""
|
||||
await super().async_initialize()
|
||||
|
||||
if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
|
||||
if self._prefs.google_settings_version < 2:
|
||||
self._migrate_google_entity_settings_v1()
|
||||
await self._prefs.async_update(
|
||||
google_settings_version=GOOGLE_SETTINGS_VERSION
|
||||
async def on_hass_started(hass: HomeAssistant) -> None:
|
||||
if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
|
||||
if self._prefs.google_settings_version < 2:
|
||||
self._migrate_google_entity_settings_v1()
|
||||
await self._prefs.async_update(
|
||||
google_settings_version=GOOGLE_SETTINGS_VERSION
|
||||
)
|
||||
async_listen_entity_updates(
|
||||
self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated
|
||||
)
|
||||
|
||||
async def hass_started(hass):
|
||||
async def on_hass_start(hass: HomeAssistant) -> None:
|
||||
if self.enabled and GOOGLE_DOMAIN not in self.hass.config.components:
|
||||
await async_setup_component(self.hass, GOOGLE_DOMAIN, {})
|
||||
|
||||
start.async_at_start(self.hass, hass_started)
|
||||
start.async_at_start(self.hass, on_hass_start)
|
||||
start.async_at_started(self.hass, on_hass_started)
|
||||
|
||||
# Remove any stored user agent id that is not ours
|
||||
remove_agent_user_ids = []
|
||||
@@ -140,9 +248,6 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
await self.async_disconnect_agent_user(agent_user_id)
|
||||
|
||||
self._prefs.async_listen_updates(self._async_prefs_updated)
|
||||
async_listen_entity_updates(
|
||||
self.hass, CLOUD_GOOGLE, self._async_exposed_entities_updated
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
er.EVENT_ENTITY_REGISTRY_UPDATED,
|
||||
self._handle_entity_registry_updated,
|
||||
@@ -180,9 +285,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."""
|
||||
@@ -215,14 +324,13 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
def should_2fa(self, state):
|
||||
"""If an entity should be checked for 2FA."""
|
||||
entity_registry = er.async_get(self.hass)
|
||||
|
||||
registry_entry = entity_registry.async_get(state.entity_id)
|
||||
if not registry_entry:
|
||||
try:
|
||||
settings = async_get_entity_settings(self.hass, state.entity_id)
|
||||
except HomeAssistantError:
|
||||
# Handle the entity has been removed
|
||||
return False
|
||||
|
||||
assistant_options = registry_entry.options.get(CLOUD_GOOGLE, {})
|
||||
assistant_options = settings.get(CLOUD_GOOGLE, {})
|
||||
return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
|
||||
|
||||
async def async_report_state(self, message, agent_user_id: str):
|
||||
@@ -308,7 +416,7 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
self.async_schedule_google_sync_all()
|
||||
|
||||
@callback
|
||||
def _handle_device_registry_updated(self, event: Event) -> None:
|
||||
async def _handle_device_registry_updated(self, event: Event) -> None:
|
||||
"""Handle when device registry updated."""
|
||||
if (
|
||||
not self.enabled
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""The HTTP api to control the cloud integration."""
|
||||
import asyncio
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
import dataclasses
|
||||
from functools import wraps
|
||||
from http import HTTPStatus
|
||||
@@ -21,14 +22,16 @@ from homeassistant.components.alexa import (
|
||||
errors as alexa_errors,
|
||||
)
|
||||
from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.location import async_detect_location_info
|
||||
|
||||
from .alexa_config import entity_supported as entity_supported_by_alexa
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
PREF_ALEXA_REPORT_STATE,
|
||||
@@ -73,6 +76,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 +202,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):
|
||||
@@ -560,15 +568,14 @@ async def google_assistant_get(
|
||||
"""Get data for a single google assistant entity."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
gconf = await cloud.client.get_google_config()
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_id: str = msg["entity_id"]
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
if not entity_registry.async_is_registered(entity_id) or not state:
|
||||
if not state:
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
websocket_api.const.ERR_NOT_FOUND,
|
||||
f"{entity_id} unknown or not in the entity registry",
|
||||
f"{entity_id} unknown",
|
||||
)
|
||||
return
|
||||
|
||||
@@ -581,10 +588,16 @@ async def google_assistant_get(
|
||||
)
|
||||
return
|
||||
|
||||
assistant_options: Mapping[str, Any] = {}
|
||||
with suppress(HomeAssistantError, KeyError):
|
||||
settings = exposed_entities.async_get_entity_settings(hass, entity_id)
|
||||
assistant_options = settings[CLOUD_GOOGLE]
|
||||
|
||||
result = {
|
||||
"entity_id": entity.entity_id,
|
||||
"traits": [trait.name for trait in entity.traits()],
|
||||
"might_2fa": entity.might_2fa_traits(),
|
||||
PREF_DISABLE_2FA: assistant_options.get(PREF_DISABLE_2FA),
|
||||
}
|
||||
|
||||
connection.send_result(msg["id"], result)
|
||||
@@ -603,14 +616,11 @@ async def google_assistant_list(
|
||||
"""List all google assistant entities."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
gconf = await cloud.client.get_google_config()
|
||||
entity_registry = er.async_get(hass)
|
||||
entities = google_helpers.async_get_entities(hass, gconf)
|
||||
|
||||
result = []
|
||||
|
||||
for entity in entities:
|
||||
if not entity_registry.async_is_registered(entity.entity_id):
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"entity_id": entity.entity_id,
|
||||
@@ -639,28 +649,51 @@ async def google_assistant_update(
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Update google assistant entity config."""
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
assistant_options: Mapping[str, Any] = {}
|
||||
with suppress(HomeAssistantError, KeyError):
|
||||
settings = exposed_entities.async_get_entity_settings(hass, entity_id)
|
||||
assistant_options = settings[CLOUD_GOOGLE]
|
||||
|
||||
disable_2fa = msg[PREF_DISABLE_2FA]
|
||||
if assistant_options.get(PREF_DISABLE_2FA) == disable_2fa:
|
||||
return
|
||||
|
||||
exposed_entities.async_set_assistant_option(
|
||||
hass, CLOUD_GOOGLE, entity_id, PREF_DISABLE_2FA, disable_2fa
|
||||
)
|
||||
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_id: str = msg["entity_id"]
|
||||
|
||||
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_ALLOWED,
|
||||
f"can't configure {entity_id}",
|
||||
websocket_api.const.ERR_NOT_SUPPORTED,
|
||||
f"{entity_id} not supported by Alexa",
|
||||
)
|
||||
return
|
||||
|
||||
disable_2fa = msg[PREF_DISABLE_2FA]
|
||||
assistant_options: Mapping[str, Any]
|
||||
if (
|
||||
assistant_options := registry_entry.options.get(CLOUD_GOOGLE, {})
|
||||
) and assistant_options.get(PREF_DISABLE_2FA) == disable_2fa:
|
||||
return
|
||||
|
||||
assistant_options = assistant_options | {PREF_DISABLE_2FA: disable_2fa}
|
||||
entity_registry.async_update_entity_options(
|
||||
entity_id, CLOUD_GOOGLE, assistant_options
|
||||
)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@@ -677,14 +710,11 @@ async def alexa_list(
|
||||
"""List all alexa entities."""
|
||||
cloud = hass.data[DOMAIN]
|
||||
alexa_config = await cloud.client.get_alexa_config()
|
||||
entity_registry = er.async_get(hass)
|
||||
entities = alexa_entities.async_get_entities(hass, alexa_config)
|
||||
|
||||
result = []
|
||||
|
||||
for entity in entities:
|
||||
if not entity_registry.async_is_registered(entity.entity_id):
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"entity_id": entity.entity_id,
|
||||
|
||||
@@ -1,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
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Const for conversation integration."""
|
||||
|
||||
DOMAIN = "conversation"
|
||||
DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||
HOME_ASSISTANT_AGENT = "homeassistant"
|
||||
|
||||
@@ -21,19 +21,21 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
from homeassistant.const import ATTR_DEVICE_CLASS
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
intent,
|
||||
start,
|
||||
template,
|
||||
translation,
|
||||
)
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .agent import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
from .const import DOMAIN
|
||||
from .const import DEFAULT_EXPOSED_ATTRIBUTES, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_DEFAULT_ERROR_TEXT = "Sorry, I couldn't understand that"
|
||||
@@ -73,6 +75,34 @@ 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_entity_state_listener(
|
||||
changed_entity: str,
|
||||
old_state: core.State | None,
|
||||
new_state: core.State | None,
|
||||
):
|
||||
"""Set expose flag on new entities."""
|
||||
if old_state is not None or new_state is None:
|
||||
return
|
||||
async_should_expose(hass, DOMAIN, changed_entity)
|
||||
|
||||
@core.callback
|
||||
def async_hass_started(hass: core.HomeAssistant) -> None:
|
||||
"""Set expose flag on all entities."""
|
||||
for state in hass.states.async_all():
|
||||
async_should_expose(hass, DOMAIN, state.entity_id)
|
||||
async_track_state_change(hass, MATCH_ALL, async_entity_state_listener)
|
||||
|
||||
start.async_at_started(hass, async_hass_started)
|
||||
|
||||
|
||||
class DefaultAgent(AbstractConversationAgent):
|
||||
"""Default agent for conversation agent."""
|
||||
|
||||
@@ -110,6 +140,11 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
self._async_handle_entity_registry_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
self.hass.bus.async_listen(
|
||||
core.EVENT_STATE_CHANGED,
|
||||
self._async_handle_state_changed,
|
||||
run_immediately=True,
|
||||
)
|
||||
async_listen_entity_updates(
|
||||
self.hass, DOMAIN, self._async_exposed_entities_updated
|
||||
)
|
||||
@@ -166,6 +201,7 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
user_input.text,
|
||||
user_input.context,
|
||||
language,
|
||||
assistant=DOMAIN,
|
||||
)
|
||||
except intent.IntentHandleError:
|
||||
_LOGGER.exception("Intent handling error")
|
||||
@@ -455,12 +491,19 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
@core.callback
|
||||
def _async_handle_entity_registry_changed(self, event: core.Event) -> None:
|
||||
"""Clear names list cache when an entity registry entry has changed."""
|
||||
if event.data["action"] == "update" and not any(
|
||||
if event.data["action"] != "update" or not any(
|
||||
field in event.data["changes"] for field in _ENTITY_REGISTRY_UPDATE_FIELDS
|
||||
):
|
||||
return
|
||||
self._slot_lists = None
|
||||
|
||||
@core.callback
|
||||
def _async_handle_state_changed(self, event: core.Event) -> None:
|
||||
"""Clear names list cache when a state is added or removed from the state machine."""
|
||||
if event.data.get("old_state") and event.data.get("new_state"):
|
||||
return
|
||||
self._slot_lists = None
|
||||
|
||||
@core.callback
|
||||
def _async_exposed_entities_updated(self) -> None:
|
||||
"""Handle updated preferences."""
|
||||
@@ -472,31 +515,39 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
return self._slot_lists
|
||||
|
||||
area_ids_with_entities: set[str] = set()
|
||||
all_entities = er.async_get(self.hass)
|
||||
entities = [
|
||||
entity
|
||||
for entity in all_entities.entities.values()
|
||||
if async_should_expose(self.hass, DOMAIN, entity.entity_id)
|
||||
entity_registry = er.async_get(self.hass)
|
||||
states = [
|
||||
state
|
||||
for state in self.hass.states.async_all()
|
||||
if async_should_expose(self.hass, DOMAIN, state.entity_id)
|
||||
]
|
||||
devices = dr.async_get(self.hass)
|
||||
|
||||
# Gather exposed entity names
|
||||
entity_names = []
|
||||
for entity in entities:
|
||||
for state in states:
|
||||
# Checked against "requires_context" and "excludes_context" in hassil
|
||||
context = {"domain": entity.domain}
|
||||
if entity.device_class:
|
||||
context[ATTR_DEVICE_CLASS] = entity.device_class
|
||||
context = {"domain": state.domain}
|
||||
if state.attributes:
|
||||
# Include some attributes
|
||||
for attr in DEFAULT_EXPOSED_ATTRIBUTES:
|
||||
if attr not in state.attributes:
|
||||
continue
|
||||
context[attr] = state.attributes[attr]
|
||||
|
||||
entity = entity_registry.async_get(state.entity_id)
|
||||
|
||||
if not entity:
|
||||
# Default name
|
||||
entity_names.append((state.name, state.name, context))
|
||||
continue
|
||||
|
||||
if entity.aliases:
|
||||
for alias in entity.aliases:
|
||||
entity_names.append((alias, alias, context))
|
||||
|
||||
# Default name
|
||||
name = entity.async_friendly_name(self.hass) or entity.entity_id.replace(
|
||||
"_", " "
|
||||
)
|
||||
entity_names.append((name, name, context))
|
||||
entity_names.append((state.name, state.name, context))
|
||||
|
||||
if entity.area_id:
|
||||
# Expose area too
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/eddystone_temperature",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["beacontools"],
|
||||
"requirements": ["beacontools[scan]==1.2.3", "construct==2.10.56"]
|
||||
"requirements": ["beacontools[scan]==2.1.0", "construct==2.10.56"]
|
||||
}
|
||||
|
||||
@@ -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==20230503.1"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
from itertools import chain
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -77,16 +78,41 @@ class AssistantPreferences:
|
||||
return {"expose_new": self.expose_new}
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class ExposedEntity:
|
||||
"""An exposed entity without a unique_id."""
|
||||
|
||||
assistants: dict[str, dict[str, Any]]
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
"""Return a JSON serializable representation for storage."""
|
||||
return {
|
||||
"assistants": self.assistants,
|
||||
}
|
||||
|
||||
|
||||
class SerializedExposedEntities(TypedDict):
|
||||
"""Serialized exposed entities storage storage collection."""
|
||||
|
||||
assistants: dict[str, dict[str, Any]]
|
||||
exposed_entities: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
class ExposedEntities:
|
||||
"""Control assistant settings."""
|
||||
"""Control assistant settings.
|
||||
|
||||
Settings for entities without a unique_id are stored in the store.
|
||||
Settings for entities with a unique_id are stored in the entity registry.
|
||||
"""
|
||||
|
||||
_assistants: dict[str, AssistantPreferences]
|
||||
entities: dict[str, ExposedEntity]
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize."""
|
||||
self._hass = hass
|
||||
self._listeners: dict[str, list[Callable[[], None]]] = {}
|
||||
self._store: Store[dict[str, dict[str, dict[str, Any]]]] = Store(
|
||||
self._store: Store[SerializedExposedEntities] = Store(
|
||||
hass, STORAGE_VERSION, STORAGE_KEY
|
||||
)
|
||||
|
||||
@@ -95,7 +121,8 @@ class ExposedEntities:
|
||||
websocket_api.async_register_command(self._hass, ws_expose_entity)
|
||||
websocket_api.async_register_command(self._hass, ws_expose_new_entities_get)
|
||||
websocket_api.async_register_command(self._hass, ws_expose_new_entities_set)
|
||||
await self.async_load()
|
||||
websocket_api.async_register_command(self._hass, ws_list_exposed_entities)
|
||||
await self._async_load_data()
|
||||
|
||||
@callback
|
||||
def async_listen_entity_updates(
|
||||
@@ -105,30 +132,57 @@ class ExposedEntities:
|
||||
self._listeners.setdefault(assistant, []).append(listener)
|
||||
|
||||
@callback
|
||||
def async_expose_entity(
|
||||
self, assistant: str, entity_id: str, should_expose: bool
|
||||
def async_set_assistant_option(
|
||||
self, assistant: str, entity_id: str, key: str, value: Any
|
||||
) -> None:
|
||||
"""Expose an entity to an assistant.
|
||||
"""Set an option for an assistant.
|
||||
|
||||
Notify listeners if expose flag was changed.
|
||||
"""
|
||||
entity_registry = er.async_get(self._hass)
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
raise HomeAssistantError("Unknown entity")
|
||||
return self._async_set_legacy_assistant_option(
|
||||
assistant, entity_id, key, value
|
||||
)
|
||||
|
||||
assistant_options: Mapping[str, Any]
|
||||
if (
|
||||
assistant_options := registry_entry.options.get(assistant, {})
|
||||
) and assistant_options.get("should_expose") == should_expose:
|
||||
) and assistant_options.get(key) == value:
|
||||
return
|
||||
|
||||
assistant_options = assistant_options | {"should_expose": should_expose}
|
||||
assistant_options = assistant_options | {key: value}
|
||||
entity_registry.async_update_entity_options(
|
||||
entity_id, assistant, assistant_options
|
||||
)
|
||||
for listener in self._listeners.get(assistant, []):
|
||||
listener()
|
||||
|
||||
def _async_set_legacy_assistant_option(
|
||||
self, assistant: str, entity_id: str, key: str, value: Any
|
||||
) -> None:
|
||||
"""Set an option for an assistant.
|
||||
|
||||
Notify listeners if expose flag was changed.
|
||||
"""
|
||||
if (
|
||||
(exposed_entity := self.entities.get(entity_id))
|
||||
and (assistant_options := exposed_entity.assistants.get(assistant, {}))
|
||||
and assistant_options.get(key) == value
|
||||
):
|
||||
return
|
||||
|
||||
if exposed_entity:
|
||||
new_exposed_entity = self._update_exposed_entity(
|
||||
assistant, entity_id, key, value
|
||||
)
|
||||
else:
|
||||
new_exposed_entity = self._new_exposed_entity(assistant, key, value)
|
||||
self.entities[entity_id] = new_exposed_entity
|
||||
self._async_schedule_save()
|
||||
for listener in self._listeners.get(assistant, []):
|
||||
listener()
|
||||
|
||||
@callback
|
||||
def async_get_expose_new_entities(self, assistant: str) -> bool:
|
||||
"""Check if new entities are exposed to an assistant."""
|
||||
@@ -150,12 +204,37 @@ class ExposedEntities:
|
||||
entity_registry = er.async_get(self._hass)
|
||||
result: dict[str, Mapping[str, Any]] = {}
|
||||
|
||||
options: Mapping | None
|
||||
for entity_id, exposed_entity in self.entities.items():
|
||||
if options := exposed_entity.assistants.get(assistant):
|
||||
result[entity_id] = options
|
||||
|
||||
for entity_id, entry in entity_registry.entities.items():
|
||||
if options := entry.options.get(assistant):
|
||||
result[entity_id] = options
|
||||
|
||||
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]] = {}
|
||||
|
||||
assistant_settings: Mapping
|
||||
if registry_entry := entity_registry.async_get(entity_id):
|
||||
assistant_settings = registry_entry.options
|
||||
elif exposed_entity := self.entities.get(entity_id):
|
||||
assistant_settings = exposed_entity.assistants
|
||||
else:
|
||||
raise HomeAssistantError("Unknown entity")
|
||||
|
||||
for assistant in KNOWN_ASSISTANTS:
|
||||
if options := assistant_settings.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."""
|
||||
@@ -166,9 +245,7 @@ class ExposedEntities:
|
||||
|
||||
entity_registry = er.async_get(self._hass)
|
||||
if not (registry_entry := entity_registry.async_get(entity_id)):
|
||||
# Entities which are not in the entity registry are not exposed
|
||||
return False
|
||||
|
||||
return self._async_should_expose_legacy_entity(assistant, entity_id)
|
||||
if assistant in registry_entry.options:
|
||||
if "should_expose" in registry_entry.options[assistant]:
|
||||
should_expose = registry_entry.options[assistant]["should_expose"]
|
||||
@@ -187,11 +264,42 @@ class ExposedEntities:
|
||||
|
||||
return should_expose
|
||||
|
||||
def _async_should_expose_legacy_entity(
|
||||
self, assistant: str, entity_id: str
|
||||
) -> bool:
|
||||
"""Return True if an entity should be exposed to an assistant."""
|
||||
should_expose: bool
|
||||
|
||||
if (
|
||||
exposed_entity := self.entities.get(entity_id)
|
||||
) and assistant in exposed_entity.assistants:
|
||||
if "should_expose" in exposed_entity.assistants[assistant]:
|
||||
should_expose = exposed_entity.assistants[assistant]["should_expose"]
|
||||
return should_expose
|
||||
|
||||
if self.async_get_expose_new_entities(assistant):
|
||||
should_expose = self._is_default_exposed(entity_id, None)
|
||||
else:
|
||||
should_expose = False
|
||||
|
||||
if exposed_entity:
|
||||
new_exposed_entity = self._update_exposed_entity(
|
||||
assistant, entity_id, "should_expose", should_expose
|
||||
)
|
||||
else:
|
||||
new_exposed_entity = self._new_exposed_entity(
|
||||
assistant, "should_expose", should_expose
|
||||
)
|
||||
self.entities[entity_id] = new_exposed_entity
|
||||
self._async_schedule_save()
|
||||
|
||||
return should_expose
|
||||
|
||||
def _is_default_exposed(
|
||||
self, entity_id: str, registry_entry: er.RegistryEntry
|
||||
self, entity_id: str, registry_entry: er.RegistryEntry | None
|
||||
) -> bool:
|
||||
"""Return True if an entity is exposed by default."""
|
||||
if (
|
||||
if registry_entry and (
|
||||
registry_entry.entity_category is not None
|
||||
or registry_entry.hidden_by is not None
|
||||
):
|
||||
@@ -201,7 +309,11 @@ class ExposedEntities:
|
||||
if domain in DEFAULT_EXPOSED_DOMAINS:
|
||||
return True
|
||||
|
||||
device_class = get_device_class(self._hass, entity_id)
|
||||
try:
|
||||
device_class = get_device_class(self._hass, entity_id)
|
||||
except HomeAssistantError:
|
||||
# The entity no longer exists
|
||||
return False
|
||||
if (
|
||||
domain == "binary_sensor"
|
||||
and device_class in DEFAULT_EXPOSED_BINARY_SENSOR_DEVICE_CLASSES
|
||||
@@ -213,17 +325,43 @@ class ExposedEntities:
|
||||
|
||||
return False
|
||||
|
||||
async def async_load(self) -> None:
|
||||
def _update_exposed_entity(
|
||||
self, assistant: str, entity_id: str, key: str, value: Any
|
||||
) -> ExposedEntity:
|
||||
"""Update an exposed entity."""
|
||||
entity = self.entities[entity_id]
|
||||
assistants = dict(entity.assistants)
|
||||
old_settings = assistants.get(assistant, {})
|
||||
assistants[assistant] = old_settings | {key: value}
|
||||
return ExposedEntity(assistants)
|
||||
|
||||
def _new_exposed_entity(
|
||||
self, assistant: str, key: str, value: Any
|
||||
) -> ExposedEntity:
|
||||
"""Create a new exposed entity."""
|
||||
return ExposedEntity(
|
||||
assistants={assistant: {key: value}},
|
||||
)
|
||||
|
||||
async def _async_load_data(self) -> SerializedExposedEntities | None:
|
||||
"""Load from the store."""
|
||||
data = await self._store.async_load()
|
||||
|
||||
assistants: dict[str, AssistantPreferences] = {}
|
||||
exposed_entities: dict[str, ExposedEntity] = {}
|
||||
|
||||
if data:
|
||||
for domain, preferences in data["assistants"].items():
|
||||
assistants[domain] = AssistantPreferences(**preferences)
|
||||
|
||||
if data and "exposed_entities" in data:
|
||||
for entity_id, preferences in data["exposed_entities"].items():
|
||||
exposed_entities[entity_id] = ExposedEntity(**preferences)
|
||||
|
||||
self._assistants = assistants
|
||||
self.entities = exposed_entities
|
||||
|
||||
return data
|
||||
|
||||
@callback
|
||||
def _async_schedule_save(self) -> None:
|
||||
@@ -231,17 +369,19 @@ class ExposedEntities:
|
||||
self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
|
||||
|
||||
@callback
|
||||
def _data_to_save(self) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
"""Return data to store in a file."""
|
||||
data = {}
|
||||
|
||||
data["assistants"] = {
|
||||
domain: preferences.to_json()
|
||||
for domain, preferences in self._assistants.items()
|
||||
def _data_to_save(self) -> SerializedExposedEntities:
|
||||
"""Return JSON-compatible date for storing to file."""
|
||||
return {
|
||||
"assistants": {
|
||||
domain: preferences.to_json()
|
||||
for domain, preferences in self._assistants.items()
|
||||
},
|
||||
"exposed_entities": {
|
||||
entity_id: entity.to_json()
|
||||
for entity_id, entity in self.entities.items()
|
||||
},
|
||||
}
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@@ -257,7 +397,6 @@ def ws_expose_entity(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Expose an entity to an assistant."""
|
||||
entity_registry = er.async_get(hass)
|
||||
entity_ids: str = msg["entity_ids"]
|
||||
|
||||
if blocked := next(
|
||||
@@ -273,28 +412,37 @@ def ws_expose_entity(
|
||||
)
|
||||
return
|
||||
|
||||
if unknown := next(
|
||||
(
|
||||
entity_id
|
||||
for entity_id in entity_ids
|
||||
if entity_id not in entity_registry.entities
|
||||
),
|
||||
None,
|
||||
):
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_NOT_FOUND, f"can't expose '{unknown}'"
|
||||
)
|
||||
return
|
||||
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
for entity_id in entity_ids:
|
||||
for assistant in msg["assistants"]:
|
||||
exposed_entities.async_expose_entity(
|
||||
assistant, entity_id, msg["should_expose"]
|
||||
)
|
||||
async_expose_entity(hass, assistant, entity_id, msg["should_expose"])
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "homeassistant/expose_entity/list",
|
||||
}
|
||||
)
|
||||
def ws_list_exposed_entities(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Expose an entity to an assistant."""
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
|
||||
result[entity_id] = {}
|
||||
entity_settings = async_get_entity_settings(hass, entity_id)
|
||||
for assistant, settings in entity_settings.items():
|
||||
if "should_expose" not in settings:
|
||||
continue
|
||||
result[entity_id][assistant] = settings["should_expose"]
|
||||
connection.send_result(msg["id"], {"exposed_entities": result})
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
@@ -348,8 +496,42 @@ 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."""
|
||||
async_set_assistant_option(
|
||||
hass, assistant, entity_id, "should_expose", 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."""
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
return exposed_entities.async_should_expose(assistant, entity_id)
|
||||
|
||||
|
||||
@callback
|
||||
def async_set_assistant_option(
|
||||
hass: HomeAssistant, assistant: str, entity_id: str, option: str, value: Any
|
||||
) -> None:
|
||||
"""Set an option for an assistant.
|
||||
|
||||
Notify listeners if expose flag was changed.
|
||||
"""
|
||||
exposed_entities: ExposedEntities = hass.data[DATA_EXPOSED_ENTITIES]
|
||||
exposed_entities.async_set_assistant_option(assistant, entity_id, option, value)
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -140,16 +140,18 @@ class GetStateIntentHandler(intent.IntentHandler):
|
||||
area=area,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
assistant=intent_obj.assistant,
|
||||
)
|
||||
)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s",
|
||||
"Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s",
|
||||
len(states),
|
||||
name,
|
||||
area,
|
||||
domains,
|
||||
device_classes,
|
||||
intent_obj.assistant,
|
||||
)
|
||||
|
||||
# Create response
|
||||
|
||||
@@ -129,7 +129,7 @@ class LocalCalendarEntity(CalendarEntity):
|
||||
recurrence_range=range_value,
|
||||
)
|
||||
except EventStoreError as err:
|
||||
raise HomeAssistantError("Error while deleting event: {err}") from err
|
||||
raise HomeAssistantError(f"Error while deleting event: {err}") from err
|
||||
await self._async_store()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
@@ -153,7 +153,7 @@ class LocalCalendarEntity(CalendarEntity):
|
||||
recurrence_range=range_value,
|
||||
)
|
||||
except EventStoreError as err:
|
||||
raise HomeAssistantError("Error while updating event: {err}") from err
|
||||
raise HomeAssistantError(f"Error while updating event: {err}") from err
|
||||
await self._async_store()
|
||||
await self.async_update_ha_state(force_refresh=True)
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nina",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynina"],
|
||||
"requirements": ["pynina==0.2.0"]
|
||||
"requirements": ["pynina==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""The ONVIF integration."""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from httpx import RequestError
|
||||
@@ -57,6 +58,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not setup camera {device.device.host}:{device.device.port}: {err}"
|
||||
) from err
|
||||
except asyncio.CancelledError as err:
|
||||
# After https://github.com/agronholm/anyio/issues/374 is resolved
|
||||
# this may be able to be removed
|
||||
await device.device.close()
|
||||
raise ConfigEntryNotReady(f"Setup was unexpectedly canceled: {err}") from err
|
||||
|
||||
if not device.available:
|
||||
raise ConfigEntryNotReady()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,7 @@ from contextlib import suppress
|
||||
import datetime as dt
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from httpx import RequestError
|
||||
import onvif
|
||||
@@ -55,6 +56,7 @@ class ONVIFDevice:
|
||||
|
||||
self.info: DeviceInfo = DeviceInfo()
|
||||
self.capabilities: Capabilities = Capabilities()
|
||||
self.onvif_capabilities: dict[str, Any] | None = None
|
||||
self.profiles: list[Profile] = []
|
||||
self.max_resolution: int = 0
|
||||
self.platforms: list[Platform] = []
|
||||
@@ -98,6 +100,11 @@ class ONVIFDevice:
|
||||
|
||||
# Get all device info
|
||||
await self.device.update_xaddrs()
|
||||
LOGGER.debug("%s: xaddrs = %s", self.name, self.device.xaddrs)
|
||||
|
||||
# Get device capabilities
|
||||
self.onvif_capabilities = await self.device.get_capabilities()
|
||||
|
||||
await self.async_check_date_and_time()
|
||||
|
||||
# Create event manager
|
||||
@@ -106,9 +113,20 @@ class ONVIFDevice:
|
||||
|
||||
# Fetch basic device info and capabilities
|
||||
self.info = await self.async_get_device_info()
|
||||
LOGGER.debug("Camera %s info = %s", self.name, self.info)
|
||||
LOGGER.debug("%s: camera info = %s", self.name, self.info)
|
||||
|
||||
#
|
||||
# We need to check capabilities before profiles, because we need the data
|
||||
# from capabilities to determine profiles correctly.
|
||||
#
|
||||
# We no longer initialize events in capabilities to avoid the problem
|
||||
# where cameras become slow to respond for a bit after starting events, and
|
||||
# instead we start events last and than update capabilities.
|
||||
#
|
||||
LOGGER.debug("%s: fetching initial capabilities", self.name)
|
||||
self.capabilities = await self.async_get_capabilities()
|
||||
LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities)
|
||||
|
||||
LOGGER.debug("%s: fetching profiles", self.name)
|
||||
self.profiles = await self.async_get_profiles()
|
||||
LOGGER.debug("Camera %s profiles = %s", self.name, self.profiles)
|
||||
|
||||
@@ -117,6 +135,7 @@ class ONVIFDevice:
|
||||
raise ONVIFError("No camera profiles found")
|
||||
|
||||
if self.capabilities.ptz:
|
||||
LOGGER.debug("%s: creating PTZ service", self.name)
|
||||
self.device.create_ptz_service()
|
||||
|
||||
# Determine max resolution from profiles
|
||||
@@ -126,6 +145,12 @@ class ONVIFDevice:
|
||||
if profile.video.encoding == "H264"
|
||||
)
|
||||
|
||||
# Start events last since some cameras become slow to respond
|
||||
# for a bit after starting events
|
||||
LOGGER.debug("%s: starting events", self.name)
|
||||
self.capabilities.events = await self.async_start_events()
|
||||
LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities)
|
||||
|
||||
async def async_stop(self, event=None):
|
||||
"""Shut it all down."""
|
||||
if self.events:
|
||||
@@ -297,16 +322,31 @@ class ONVIFDevice:
|
||||
self.device.create_imaging_service()
|
||||
imaging = True
|
||||
|
||||
events = False
|
||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError):
|
||||
events = await self.events.async_start()
|
||||
return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging)
|
||||
|
||||
return Capabilities(snapshot, events, ptz, imaging)
|
||||
async def async_start_events(self):
|
||||
"""Start the event handler."""
|
||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError):
|
||||
onvif_capabilities = self.onvif_capabilities or {}
|
||||
pull_point_support = onvif_capabilities.get("Events", {}).get(
|
||||
"WSPullPointSupport"
|
||||
)
|
||||
LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support)
|
||||
return await self.events.async_start(pull_point_support is not False, True)
|
||||
|
||||
return False
|
||||
|
||||
async def async_get_profiles(self) -> list[Profile]:
|
||||
"""Obtain media profiles for this device."""
|
||||
media_service = self.device.create_media_service()
|
||||
result = await media_service.GetProfiles()
|
||||
LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr)
|
||||
try:
|
||||
result = await media_service.GetProfiles()
|
||||
except GET_CAPABILITIES_EXCEPTIONS:
|
||||
LOGGER.debug(
|
||||
"%s: Could not get profiles from ONVIF device", self.name, exc_info=True
|
||||
)
|
||||
raise
|
||||
profiles: list[Profile] = []
|
||||
|
||||
if not isinstance(result, list):
|
||||
|
||||
@@ -11,7 +11,7 @@ from httpx import RemoteProtocolError, RequestError, TransportError
|
||||
from onvif import ONVIFCamera, ONVIFService
|
||||
from onvif.client import NotificationManager
|
||||
from onvif.exceptions import ONVIFError
|
||||
from zeep.exceptions import Fault, XMLParseError
|
||||
from zeep.exceptions import Fault, ValidationError, XMLParseError
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -35,7 +35,7 @@ from .util import stringify_onvif_error
|
||||
UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"}
|
||||
|
||||
SUBSCRIPTION_ERRORS = (Fault, asyncio.TimeoutError, TransportError)
|
||||
CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError)
|
||||
CREATE_ERRORS = (ONVIFError, Fault, RequestError, XMLParseError, ValidationError)
|
||||
SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError)
|
||||
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||
@@ -123,11 +123,13 @@ class EventManager:
|
||||
if not self._listeners:
|
||||
self.pullpoint_manager.async_cancel_pull_messages()
|
||||
|
||||
async def async_start(self) -> bool:
|
||||
async def async_start(self, try_pullpoint: bool, try_webhook: bool) -> bool:
|
||||
"""Start polling events."""
|
||||
# Always start pull point first, since it will populate the event list
|
||||
event_via_pull_point = await self.pullpoint_manager.async_start()
|
||||
events_via_webhook = await self.webhook_manager.async_start()
|
||||
event_via_pull_point = (
|
||||
try_pullpoint and await self.pullpoint_manager.async_start()
|
||||
)
|
||||
events_via_webhook = try_webhook and await self.webhook_manager.async_start()
|
||||
return events_via_webhook or event_via_pull_point
|
||||
|
||||
async def async_stop(self) -> None:
|
||||
@@ -655,16 +657,34 @@ class WebHookManager:
|
||||
|
||||
async def _async_create_webhook_subscription(self) -> None:
|
||||
"""Create webhook subscription."""
|
||||
LOGGER.debug("%s: Creating webhook subscription", self._name)
|
||||
LOGGER.debug(
|
||||
"%s: Creating webhook subscription with URL: %s",
|
||||
self._name,
|
||||
self._webhook_url,
|
||||
)
|
||||
self._notification_manager = self._device.create_notification_manager(
|
||||
{
|
||||
"InitialTerminationTime": SUBSCRIPTION_RELATIVE_TIME,
|
||||
"ConsumerReference": {"Address": self._webhook_url},
|
||||
}
|
||||
)
|
||||
self._webhook_subscription = await self._notification_manager.setup()
|
||||
try:
|
||||
self._webhook_subscription = await self._notification_manager.setup()
|
||||
except ValidationError as err:
|
||||
# This should only happen if there is a problem with the webhook URL
|
||||
# that is causing it to not be well formed.
|
||||
LOGGER.exception(
|
||||
"%s: validation error while creating webhook subscription: %s",
|
||||
self._name,
|
||||
err,
|
||||
)
|
||||
raise
|
||||
await self._notification_manager.start()
|
||||
LOGGER.debug("%s: Webhook subscription created", self._name)
|
||||
LOGGER.debug(
|
||||
"%s: Webhook subscription created with URL: %s",
|
||||
self._name,
|
||||
self._webhook_url,
|
||||
)
|
||||
|
||||
async def _async_start_webhook(self) -> bool:
|
||||
"""Start webhook."""
|
||||
@@ -769,6 +789,7 @@ class WebHookManager:
|
||||
return
|
||||
|
||||
webhook_id = self._webhook_unique_id
|
||||
self._async_unregister_webhook()
|
||||
webhook.async_register(
|
||||
self._hass, DOMAIN, webhook_id, webhook_id, self._async_handle_webhook
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/onvif",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||
"requirements": ["onvif-zeep-async==1.3.0", "WSDiscovery==2.0.0"]
|
||||
"requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"]
|
||||
}
|
||||
|
||||
@@ -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.12",
|
||||
"fnv-hash-fast==0.3.1",
|
||||
"psutil-home-assistant==0.0.1"
|
||||
]
|
||||
|
||||
@@ -1158,23 +1158,23 @@ def _wipe_old_string_time_columns(
|
||||
elif engine.dialect.name == SupportedDialect.MYSQL:
|
||||
#
|
||||
# Since this is only to save space we limit the number of rows we update
|
||||
# to 10,000,000 per table since we do not want to block the database for too long
|
||||
# to 100,000 per table since we do not want to block the database for too long
|
||||
# or run out of innodb_buffer_pool_size on MySQL. The old data will eventually
|
||||
# be cleaned up by the recorder purge if we do not do it now.
|
||||
#
|
||||
session.execute(text("UPDATE events set time_fired=NULL LIMIT 10000000;"))
|
||||
session.execute(text("UPDATE events set time_fired=NULL LIMIT 100000;"))
|
||||
session.commit()
|
||||
session.execute(
|
||||
text(
|
||||
"UPDATE states set last_updated=NULL, last_changed=NULL "
|
||||
" LIMIT 10000000;"
|
||||
" LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
|
||||
#
|
||||
# Since this is only to save space we limit the number of rows we update
|
||||
# to 250,000 per table since we do not want to block the database for too long
|
||||
# to 100,000 per table since we do not want to block the database for too long
|
||||
# or run out ram with postgresql. The old data will eventually
|
||||
# be cleaned up by the recorder purge if we do not do it now.
|
||||
#
|
||||
@@ -1182,7 +1182,7 @@ def _wipe_old_string_time_columns(
|
||||
text(
|
||||
"UPDATE events set time_fired=NULL "
|
||||
"where event_id in "
|
||||
"(select event_id from events where time_fired_ts is NOT NULL LIMIT 250000);"
|
||||
"(select event_id from events where time_fired_ts is NOT NULL LIMIT 100000);"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -1190,7 +1190,7 @@ def _wipe_old_string_time_columns(
|
||||
text(
|
||||
"UPDATE states set last_updated=NULL, last_changed=NULL "
|
||||
"where state_id in "
|
||||
"(select state_id from states where last_updated_ts is NOT NULL LIMIT 250000);"
|
||||
"(select state_id from states where last_updated_ts is NOT NULL LIMIT 100000);"
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
@@ -1236,7 +1236,7 @@ def _migrate_columns_to_timestamp(
|
||||
"UNIX_TIMESTAMP(time_fired)"
|
||||
") "
|
||||
"where time_fired_ts is NULL "
|
||||
"LIMIT 250000;"
|
||||
"LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
result = None
|
||||
@@ -1251,7 +1251,7 @@ def _migrate_columns_to_timestamp(
|
||||
"last_changed_ts="
|
||||
"UNIX_TIMESTAMP(last_changed) "
|
||||
"where last_updated_ts is NULL "
|
||||
"LIMIT 250000;"
|
||||
"LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
elif engine.dialect.name == SupportedDialect.POSTGRESQL:
|
||||
@@ -1266,7 +1266,7 @@ def _migrate_columns_to_timestamp(
|
||||
"time_fired_ts= "
|
||||
"(case when time_fired is NULL then 0 else EXTRACT(EPOCH FROM time_fired::timestamptz) end) "
|
||||
"WHERE event_id IN ( "
|
||||
"SELECT event_id FROM events where time_fired_ts is NULL LIMIT 250000 "
|
||||
"SELECT event_id FROM events where time_fired_ts is NULL LIMIT 100000 "
|
||||
" );"
|
||||
)
|
||||
)
|
||||
@@ -1279,7 +1279,7 @@ def _migrate_columns_to_timestamp(
|
||||
"(case when last_updated is NULL then 0 else EXTRACT(EPOCH FROM last_updated::timestamptz) end), "
|
||||
"last_changed_ts=EXTRACT(EPOCH FROM last_changed::timestamptz) "
|
||||
"where state_id IN ( "
|
||||
"SELECT state_id FROM states where last_updated_ts is NULL LIMIT 250000 "
|
||||
"SELECT state_id FROM states where last_updated_ts is NULL LIMIT 100000 "
|
||||
" );"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2314,7 +2314,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool:
|
||||
session.connection()
|
||||
.execute(
|
||||
text(
|
||||
f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 250000;"
|
||||
f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL where start is not NULL LIMIT 100000;"
|
||||
)
|
||||
)
|
||||
.rowcount
|
||||
@@ -2330,7 +2330,7 @@ def cleanup_statistics_timestamp_migration(instance: Recorder) -> bool:
|
||||
.execute(
|
||||
text(
|
||||
f"UPDATE {table} set start=NULL, created=NULL, last_reset=NULL " # nosec
|
||||
f"where id in (select id from {table} where start is not NULL LIMIT 250000)"
|
||||
f"where id in (select id from {table} where start is not NULL LIMIT 100000)"
|
||||
)
|
||||
)
|
||||
.rowcount
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -10,7 +10,7 @@ from sense_energy import (
|
||||
)
|
||||
|
||||
DOMAIN = "sense"
|
||||
DEFAULT_TIMEOUT = 10
|
||||
DEFAULT_TIMEOUT = 30
|
||||
ACTIVE_UPDATE_RATE = 60
|
||||
DEFAULT_NAME = "Sense"
|
||||
SENSE_DATA = "sense_data"
|
||||
|
||||
@@ -91,6 +91,11 @@ SERVICE_SCHEMA_FEEDBACK = vol.Schema(
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Activate Snips component."""
|
||||
|
||||
# Make sure MQTT integration is enabled and the client is available
|
||||
if not await mqtt.async_wait_for_mqtt_client(hass):
|
||||
_LOGGER.error("MQTT integration is not available")
|
||||
return False
|
||||
|
||||
async def async_set_feedback(site_ids, state):
|
||||
"""Set Feedback sound state."""
|
||||
site_ids = site_ids if site_ids else config[DOMAIN].get(CONF_SITE_IDS)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""The sql component."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.recorder import CONF_DB_URL, get_instance
|
||||
@@ -24,6 +26,9 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN, PLATFORMS
|
||||
from .util import redact_credentials
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def validate_sql_select(value: str) -> str:
|
||||
@@ -85,6 +90,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up SQL from a config entry."""
|
||||
_LOGGER.debug(
|
||||
"Comparing %s and %s",
|
||||
redact_credentials(entry.options.get(CONF_DB_URL)),
|
||||
redact_credentials(get_instance(hass).db_url),
|
||||
)
|
||||
if entry.options.get(CONF_DB_URL) == get_instance(hass).db_url:
|
||||
remove_configured_db_url_if_not_needed(hass, entry)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.recorder import CONF_DB_URL
|
||||
from homeassistant.components.recorder import CONF_DB_URL, get_instance
|
||||
from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
@@ -159,13 +159,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
|
||||
class SQLOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry):
|
||||
"""Handle SQL options."""
|
||||
|
||||
def __init__(self, entry: config_entries.ConfigEntry) -> None:
|
||||
"""Initialize SQL options flow."""
|
||||
self.entry = entry
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
@@ -177,7 +173,7 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
db_url = user_input.get(CONF_DB_URL)
|
||||
query = user_input[CONF_QUERY]
|
||||
column = user_input[CONF_COLUMN_NAME]
|
||||
name = self.entry.options.get(CONF_NAME, self.entry.title)
|
||||
name = self.options.get(CONF_NAME, self.config_entry.title)
|
||||
|
||||
try:
|
||||
validate_sql_select(query)
|
||||
@@ -193,21 +189,26 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlow):
|
||||
except ValueError:
|
||||
errors["query"] = "query_invalid"
|
||||
else:
|
||||
new_user_input = user_input
|
||||
if new_user_input.get(CONF_DB_URL) and db_url == db_url_for_validation:
|
||||
new_user_input.pop(CONF_DB_URL)
|
||||
recorder_db = get_instance(self.hass).db_url
|
||||
_LOGGER.debug(
|
||||
"db_url: %s, resolved db_url: %s, recorder: %s",
|
||||
db_url,
|
||||
db_url_for_validation,
|
||||
recorder_db,
|
||||
)
|
||||
if db_url and db_url_for_validation == recorder_db:
|
||||
user_input.pop(CONF_DB_URL)
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
data={
|
||||
CONF_NAME: name,
|
||||
**new_user_input,
|
||||
**user_input,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
OPTIONS_SCHEMA, user_input or self.entry.options
|
||||
OPTIONS_SCHEMA, user_input or self.options
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sql",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["sqlalchemy==2.0.10"]
|
||||
"requirements": ["sqlalchemy==2.0.12"]
|
||||
}
|
||||
|
||||
@@ -42,20 +42,15 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.template import Template
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DB_URL_RE, DOMAIN
|
||||
from .const import CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
|
||||
from .models import SQLData
|
||||
from .util import resolve_db_url
|
||||
from .util import redact_credentials, resolve_db_url
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_SQL_LAMBDA_CACHE: LRUCache = LRUCache(1000)
|
||||
|
||||
|
||||
def redact_credentials(data: str) -> str:
|
||||
"""Redact credentials from string data."""
|
||||
return DB_URL_RE.sub("//****:****@", data)
|
||||
|
||||
|
||||
async def async_setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
|
||||
@@ -1,12 +1,26 @@
|
||||
"""Utils for sql."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.recorder import get_instance
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DB_URL_RE
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def redact_credentials(data: str | None) -> str:
|
||||
"""Redact credentials from string data."""
|
||||
if not data:
|
||||
return "none"
|
||||
return DB_URL_RE.sub("//****:****@", data)
|
||||
|
||||
|
||||
def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
|
||||
"""Return the db_url provided if not empty, otherwise return the recorder db_url."""
|
||||
_LOGGER.debug("db_url: %s", redact_credentials(db_url))
|
||||
if db_url and not db_url.isspace():
|
||||
return db_url
|
||||
return get_instance(hass).db_url
|
||||
|
||||
@@ -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):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user