mirror of
https://github.com/home-assistant/core.git
synced 2026-01-04 14:55:39 +01:00
Compare commits
196 Commits
hassio-spl
...
2025.7.0b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fc3fa51a8 | ||
|
|
4eb688b560 | ||
|
|
9472ff5d36 | ||
|
|
12e8b81ec7 | ||
|
|
ec5e543c09 | ||
|
|
116c745872 | ||
|
|
1fdf152292 | ||
|
|
b816f1a408 | ||
|
|
eb351e6505 | ||
|
|
2f27d55495 | ||
|
|
fa1bed1849 | ||
|
|
b8c19f23f3 | ||
|
|
b677ce6c90 | ||
|
|
0e6bbb30c1 | ||
|
|
fdba791f18 | ||
|
|
d4dec6c7a9 | ||
|
|
f838e85a79 | ||
|
|
04ae966544 | ||
|
|
b2c393db72 | ||
|
|
3ed440a3af | ||
|
|
01e7efc7b4 | ||
|
|
60a930554a | ||
|
|
c707bf6264 | ||
|
|
3548ab70fd | ||
|
|
e272ab1885 | ||
|
|
d5d1b620d0 | ||
|
|
8b2f4f0f86 | ||
|
|
725269ecda | ||
|
|
c42fc818bf | ||
|
|
5554e38171 | ||
|
|
b25acfe823 | ||
|
|
ff25948e37 | ||
|
|
f85fc7173f | ||
|
|
748cc6386d | ||
|
|
47b232db49 | ||
|
|
c61935fc41 | ||
|
|
414318f3fb | ||
|
|
08985d783f | ||
|
|
e4bcde7d20 | ||
|
|
db04c77e62 | ||
|
|
e8204e5f8e | ||
|
|
66cf9c4ed5 | ||
|
|
1f6d28dcbf | ||
|
|
328e838351 | ||
|
|
62a1c8af11 | ||
|
|
b50e599517 | ||
|
|
3c7c9176d2 | ||
|
|
c771f5fe1e | ||
|
|
6dc464ad73 | ||
|
|
ae48e3716e | ||
|
|
1543726095 | ||
|
|
adbace95c3 | ||
|
|
578b43cf61 | ||
|
|
a8b5d1511d | ||
|
|
5a0a1bbbf4 | ||
|
|
cf2e69ed74 | ||
|
|
c32b44b774 | ||
|
|
2f69ed4a8a | ||
|
|
4b3449fe0c | ||
|
|
33e1c6de68 | ||
|
|
81e712ea49 | ||
|
|
d3c5684cd0 | ||
|
|
862b7460b5 | ||
|
|
a65eb57539 | ||
|
|
b537850f52 | ||
|
|
16c6bd08f8 | ||
|
|
18834849c2 | ||
|
|
e4d820799f | ||
|
|
013a35176a | ||
|
|
8230557aef | ||
|
|
5451063714 | ||
|
|
8cdc7523a4 | ||
|
|
77ccfbd3a9 | ||
|
|
4977ee4998 | ||
|
|
5c0f2d37f0 | ||
|
|
0b5d2ab8e4 | ||
|
|
47f3bf29dd | ||
|
|
62f7cbb51e | ||
|
|
b9e2c5d34c | ||
|
|
1829acd0e1 | ||
|
|
41b9a7a9a3 | ||
|
|
9782637ec8 | ||
|
|
6bd6fa65d2 | ||
|
|
85343a9f53 | ||
|
|
bc607dd013 | ||
|
|
c2c388e0cc | ||
|
|
3fc154e1d7 | ||
|
|
efb29d024e | ||
|
|
263823c92c | ||
|
|
e5e6ed601b | ||
|
|
28dfc997f3 | ||
|
|
f93ab8d519 | ||
|
|
cb359da79e | ||
|
|
6a7385590a | ||
|
|
c0ec987b07 | ||
|
|
26521f8cc0 | ||
|
|
4df1f702bf | ||
|
|
c8422c9fb8 | ||
|
|
f8207a2e0e | ||
|
|
9cc75f3458 | ||
|
|
a233b6b1e3 | ||
|
|
c7677b91da | ||
|
|
1f57bba9cd | ||
|
|
4cc10ca2e2 | ||
|
|
153e1e43e8 | ||
|
|
398dd3ae46 | ||
|
|
17fd850fa6 | ||
|
|
ae062b230c | ||
|
|
d523f85404 | ||
|
|
f28d6582c6 | ||
|
|
1e81e5990e | ||
|
|
5fe2e4b6ed | ||
|
|
914bb3aa76 | ||
|
|
cfa6746115 | ||
|
|
03f9caf3eb | ||
|
|
6b2aaf3fdb | ||
|
|
2c4ea0d584 | ||
|
|
e627811f7a | ||
|
|
150f41641b | ||
|
|
b9a7371996 | ||
|
|
7d0e99da43 | ||
|
|
71f281cc14 | ||
|
|
aec812a475 | ||
|
|
d4b548b169 | ||
|
|
a296324c30 | ||
|
|
cff3d3d6ac | ||
|
|
26e3caea9a | ||
|
|
2b5f5f641d | ||
|
|
99079d2980 | ||
|
|
2800921a5d | ||
|
|
3268b9ee18 | ||
|
|
02c3cdd5d4 | ||
|
|
f34f17bc24 | ||
|
|
1fb587bf03 | ||
|
|
d8258924f7 | ||
|
|
c05d8aab1c | ||
|
|
e210681751 | ||
|
|
809aced9cc | ||
|
|
977e8adbfb | ||
|
|
c54ce7eabd | ||
|
|
c5f8acfe93 | ||
|
|
8393f17bb3 | ||
|
|
8918b0d7a9 | ||
|
|
c447729ce4 | ||
|
|
12812049ea | ||
|
|
47811e13a6 | ||
|
|
7587fc985f | ||
|
|
716ec1eef2 | ||
|
|
b95af2d86b | ||
|
|
bca7502611 | ||
|
|
1e4fbebf49 | ||
|
|
c9e9575a3d | ||
|
|
f897a728f1 | ||
|
|
0bbb168862 | ||
|
|
0a884c7253 | ||
|
|
58e60fdfac | ||
|
|
33bd35bff4 | ||
|
|
f4b95ff5f1 | ||
|
|
f800248c10 | ||
|
|
d0b2d1dc92 | ||
|
|
85e9919bbd | ||
|
|
51fb1ab8b6 | ||
|
|
066e840e06 | ||
|
|
7031167895 | ||
|
|
69bf79d3bd | ||
|
|
909d950b50 | ||
|
|
51da1bc25a | ||
|
|
f22b623968 | ||
|
|
2bcdc03661 | ||
|
|
10d1affd81 | ||
|
|
91e7b75a44 | ||
|
|
42aaa888a1 | ||
|
|
7b8ebb0803 | ||
|
|
c270ea4e0c | ||
|
|
c93e45c0f2 | ||
|
|
19b773df85 | ||
|
|
9e7c7ec97e | ||
|
|
f735331699 | ||
|
|
5a20ef3f3f | ||
|
|
5ef054f2e0 | ||
|
|
b9fc198a7e | ||
|
|
ad4fae7f59 | ||
|
|
265de91fba | ||
|
|
7322fe40da | ||
|
|
8eb906fad9 | ||
|
|
4d9843172b | ||
|
|
e8a534be9c | ||
|
|
3148719864 | ||
|
|
abfb7afcb7 | ||
|
|
fe4ff4f835 | ||
|
|
cefc8822b6 | ||
|
|
3dc8676b99 | ||
|
|
0f112bb9c4 | ||
|
|
54e5107c34 | ||
|
|
657a068087 | ||
|
|
af6c2b5c8a |
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@@ -1169,8 +1169,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ping/ @jpbede
|
||||
/homeassistant/components/plaato/ @JohNan
|
||||
/tests/components/plaato/ @JohNan
|
||||
/homeassistant/components/playstation_network/ @jackjpowell
|
||||
/tests/components/playstation_network/ @jackjpowell
|
||||
/homeassistant/components/playstation_network/ @jackjpowell @tr4nt0r
|
||||
/tests/components/playstation_network/ @jackjpowell @tr4nt0r
|
||||
/homeassistant/components/plex/ @jjlawren
|
||||
/tests/components/plex/ @jjlawren
|
||||
/homeassistant/components/plugwise/ @CoMPaTech @bouwew
|
||||
@@ -1553,6 +1553,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/technove/ @Moustachauve
|
||||
/homeassistant/components/tedee/ @patrickhilker @zweckj
|
||||
/tests/components/tedee/ @patrickhilker @zweckj
|
||||
/homeassistant/components/telegram_bot/ @hanwg
|
||||
/tests/components/telegram_bot/ @hanwg
|
||||
/homeassistant/components/tellduslive/ @fredrike
|
||||
/tests/components/tellduslive/ @fredrike
|
||||
/homeassistant/components/template/ @Petro31 @home-assistant/core
|
||||
|
||||
@@ -89,6 +89,7 @@ from .helpers import (
|
||||
restore_state,
|
||||
template,
|
||||
translation,
|
||||
trigger,
|
||||
)
|
||||
from .helpers.dispatcher import async_dispatcher_send_internal
|
||||
from .helpers.storage import get_internal_store_manager
|
||||
@@ -452,6 +453,7 @@ async def async_load_base_functionality(hass: core.HomeAssistant) -> None:
|
||||
create_eager_task(restore_state.async_load(hass)),
|
||||
create_eager_task(hass.config_entries.async_initialize()),
|
||||
create_eager_task(async_get_system_info(hass)),
|
||||
create_eager_task(trigger.async_setup(hass)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -185,6 +185,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
keys=[AOD_TOWN, AOD_FORECAST_DAILY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||
name="Daily forecast wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -192,6 +193,7 @@ FORECAST_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
keys=[AOD_TOWN, AOD_FORECAST_HOURLY, AOD_FORECAST_CURRENT, AOD_WIND_DIRECTION],
|
||||
name="Hourly forecast wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
entity_registry_enabled_default=False,
|
||||
@@ -334,7 +336,8 @@ WEATHER_SENSORS: Final[tuple[AemetSensorEntityDescription, ...]] = (
|
||||
keys=[AOD_WEATHER, AOD_WIND_DIRECTION],
|
||||
name="Wind bearing",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
),
|
||||
AemetSensorEntityDescription(
|
||||
key=ATTR_API_WIND_MAX_SPEED,
|
||||
|
||||
@@ -71,7 +71,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def _async_update_data(self) -> dict[str, Any]:
|
||||
"""Update data via library."""
|
||||
data = {}
|
||||
data: dict[str, Any] = {}
|
||||
try:
|
||||
obs = await self.airnow.observations.latLong(
|
||||
self.latitude,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airnow",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyairnow"],
|
||||
"requirements": ["pyairnow==1.2.1"]
|
||||
"requirements": ["pyairnow==1.3.1"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from aioairq.core import AirQ, identify_warming_up_sensors
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
@@ -39,7 +39,7 @@ class AirQCoordinator(DataUpdateCoordinator):
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=UPDATE_INTERVAL),
|
||||
)
|
||||
session = async_get_clientsession(hass)
|
||||
session = async_create_clientsession(hass)
|
||||
self.airq = AirQ(
|
||||
entry.data[CONF_IP_ADDRESS], entry.data[CONF_PASSWORD], session
|
||||
)
|
||||
|
||||
@@ -29,5 +29,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bo
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
await entry.runtime_data.api.close()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
coordinator = entry.runtime_data
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
await coordinator.api.close()
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -7,6 +7,7 @@ from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -28,7 +29,8 @@ PARALLEL_UPDATES = 0
|
||||
class AmazonBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
"""Alexa Devices binary sensor entity description."""
|
||||
|
||||
is_on_fn: Callable[[AmazonDevice], bool]
|
||||
is_on_fn: Callable[[AmazonDevice, str], bool]
|
||||
is_supported: Callable[[AmazonDevice, str], bool] = lambda device, key: True
|
||||
|
||||
|
||||
BINARY_SENSORS: Final = (
|
||||
@@ -36,13 +38,49 @@ BINARY_SENSORS: Final = (
|
||||
key="online",
|
||||
device_class=BinarySensorDeviceClass.CONNECTIVITY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
is_on_fn=lambda _device: _device.online,
|
||||
is_on_fn=lambda device, _: device.online,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="bluetooth",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
translation_key="bluetooth",
|
||||
is_on_fn=lambda _device: _device.bluetooth_state,
|
||||
is_on_fn=lambda device, _: device.bluetooth_state,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="babyCryDetectionState",
|
||||
translation_key="baby_cry_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="beepingApplianceDetectionState",
|
||||
translation_key="beeping_appliance_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="coughDetectionState",
|
||||
translation_key="cough_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="dogBarkDetectionState",
|
||||
translation_key="dog_bark_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="humanPresenceDetectionState",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
AmazonBinarySensorEntityDescription(
|
||||
key="waterSoundsDetectionState",
|
||||
translation_key="water_sounds_detection",
|
||||
is_on_fn=lambda device, key: (device.sensors[key].value != SENSOR_STATE_OFF),
|
||||
is_supported=lambda device, key: device.sensors.get(key) is not None,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -60,6 +98,7 @@ async def async_setup_entry(
|
||||
AmazonBinarySensorEntity(coordinator, serial_num, sensor_desc)
|
||||
for sensor_desc in BINARY_SENSORS
|
||||
for serial_num in coordinator.data
|
||||
if sensor_desc.is_supported(coordinator.data[serial_num], sensor_desc.key)
|
||||
)
|
||||
|
||||
|
||||
@@ -71,4 +110,6 @@ class AmazonBinarySensorEntity(AmazonEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self.entity_description.is_on_fn(self.device)
|
||||
return self.entity_description.is_on_fn(
|
||||
self.device, self.entity_description.key
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
|
||||
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -36,6 +36,8 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except WrongCountry:
|
||||
errors["base"] = "wrong_country"
|
||||
else:
|
||||
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
@@ -2,9 +2,39 @@
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"default": "mdi:bluetooth",
|
||||
"default": "mdi:bluetooth-off",
|
||||
"state": {
|
||||
"off": "mdi:bluetooth-off"
|
||||
"on": "mdi:bluetooth"
|
||||
}
|
||||
},
|
||||
"baby_cry_detection": {
|
||||
"default": "mdi:account-voice-off",
|
||||
"state": {
|
||||
"on": "mdi:account-voice"
|
||||
}
|
||||
},
|
||||
"beeping_appliance_detection": {
|
||||
"default": "mdi:bell-off",
|
||||
"state": {
|
||||
"on": "mdi:bell-ring"
|
||||
}
|
||||
},
|
||||
"cough_detection": {
|
||||
"default": "mdi:blur-off",
|
||||
"state": {
|
||||
"on": "mdi:blur"
|
||||
}
|
||||
},
|
||||
"dog_bark_detection": {
|
||||
"default": "mdi:dog-side-off",
|
||||
"state": {
|
||||
"on": "mdi:dog-side"
|
||||
}
|
||||
},
|
||||
"water_sounds_detection": {
|
||||
"default": "mdi:water-pump-off",
|
||||
"state": {
|
||||
"on": "mdi:water-pump"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.1.14"]
|
||||
"requirements": ["aioamazondevices==3.2.2"]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -70,6 +71,7 @@ class AmazonNotifyEntity(AmazonEntity, NotifyEntity):
|
||||
|
||||
entity_description: AmazonNotifyEntityDescription
|
||||
|
||||
@alexa_api_call
|
||||
async def async_send_message(
|
||||
self, message: str, title: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
|
||||
@@ -26,7 +26,7 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"data_country": "Country code",
|
||||
"data_code": "One-time password (OTP code)",
|
||||
"data_description_country": "The country of your Amazon account.",
|
||||
"data_description_country": "The country where your Amazon account is registered.",
|
||||
"data_description_username": "The email address of your Amazon account.",
|
||||
"data_description_password": "The password of your Amazon account.",
|
||||
"data_description_code": "The one-time password to log in to your account. Currently, only tokens from OTP applications are supported."
|
||||
@@ -12,10 +11,10 @@
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"country": "[%key:component::alexa_devices::common::data_country%]",
|
||||
"country": "[%key:common::config_flow::data::country%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"code": "[%key:component::alexa_devices::common::data_description_code%]"
|
||||
"code": "[%key:component::alexa_devices::common::data_code%]"
|
||||
},
|
||||
"data_description": {
|
||||
"country": "[%key:component::alexa_devices::common::data_description_country%]",
|
||||
@@ -34,6 +33,7 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
@@ -41,6 +41,21 @@
|
||||
"binary_sensor": {
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth"
|
||||
},
|
||||
"baby_cry_detection": {
|
||||
"name": "Baby crying"
|
||||
},
|
||||
"beeping_appliance_detection": {
|
||||
"name": "Beeping appliance"
|
||||
},
|
||||
"cough_detection": {
|
||||
"name": "Coughing"
|
||||
},
|
||||
"dog_bark_detection": {
|
||||
"name": "Dog barking"
|
||||
},
|
||||
"water_sounds_detection": {
|
||||
"name": "Water sounds"
|
||||
}
|
||||
},
|
||||
"notify": {
|
||||
@@ -56,5 +71,13 @@
|
||||
"name": "Do not disturb"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect": {
|
||||
"message": "Error connecting: {error}"
|
||||
},
|
||||
"cannot_retrieve_data": {
|
||||
"message": "Error retrieving data: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import AmazonConfigEntry
|
||||
from .entity import AmazonEntity
|
||||
from .utils import alexa_api_call
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -60,6 +61,7 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
|
||||
|
||||
entity_description: AmazonSwitchEntityDescription
|
||||
|
||||
@alexa_api_call
|
||||
async def _switch_set_state(self, state: bool) -> None:
|
||||
"""Set desired switch state."""
|
||||
method = getattr(self.coordinator.api, self.entity_description.method)
|
||||
|
||||
40
homeassistant/components/alexa_devices/utils.py
Normal file
40
homeassistant/components/alexa_devices/utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Utils for Alexa Devices."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AmazonEntity
|
||||
|
||||
|
||||
def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
func: Callable[Concatenate[_T, _P], Awaitable[None]],
|
||||
) -> Callable[Concatenate[_T, _P], Coroutine[Any, Any, None]]:
|
||||
"""Catch Alexa API call exceptions."""
|
||||
|
||||
@wraps(func)
|
||||
async def cmd_wrapper(self: _T, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
"""Wrap all command methods."""
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except CannotConnect as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except CannotRetrieveData as err:
|
||||
self.coordinator.last_update_success = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_retrieve_data",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
|
||||
return cmd_wrapper
|
||||
@@ -17,7 +17,13 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_CHAT_MODEL, DOMAIN, LOGGER, RECOMMENDED_CHAT_MODEL
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -117,12 +123,49 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
else:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -75,6 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -260,11 +260,18 @@ class APIEntityStateView(HomeAssistantView):
|
||||
if not user.is_admin:
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
hass = request.app[KEY_HASS]
|
||||
|
||||
body = await request.text()
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
data: Any = json_loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return self.json_message(
|
||||
"State data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
if (new_state := data.get("state")) is None:
|
||||
return self.json_message("No state specified.", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
@@ -477,9 +484,19 @@ class APITemplateView(HomeAssistantView):
|
||||
@require_admin
|
||||
async def post(self, request: web.Request) -> web.Response:
|
||||
"""Render a template."""
|
||||
body = await request.text()
|
||||
|
||||
try:
|
||||
data: Any = json_loads(body) if body else None
|
||||
except ValueError:
|
||||
return self.json_message("Invalid JSON specified.", HTTPStatus.BAD_REQUEST)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return self.json_message(
|
||||
"Template data should be a JSON object.", HTTPStatus.BAD_REQUEST
|
||||
)
|
||||
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
||||
try:
|
||||
data = await request.json()
|
||||
tpl = _cached_template(data["template"], request.app[KEY_HASS])
|
||||
return tpl.async_render(variables=data.get("variables"), parse_result=False) # type: ignore[no-any-return]
|
||||
except (ValueError, TemplateError) as ex:
|
||||
return self.json_message(
|
||||
|
||||
@@ -1119,6 +1119,7 @@ class PipelineRun:
|
||||
) is not None:
|
||||
# Sentence trigger matched
|
||||
agent_id = "sentence_trigger"
|
||||
processed_locally = True
|
||||
intent_response = intent.IntentResponse(
|
||||
self.pipeline.conversation_language
|
||||
)
|
||||
|
||||
@@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): str,
|
||||
vol.Optional("media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
@@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
"start_conversation",
|
||||
vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("start_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
@@ -135,9 +136,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): str,
|
||||
vol.Optional("question_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("answers"): [
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
raise vol.Invalid("sentences cannot be empty")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# Validator for media_id fields that accepts both string and media selector format
|
||||
_media_id_validator = vol.Any(
|
||||
cv.string, # Plain string format
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("media_content_id"): cv.string,
|
||||
vol.Required("media_content_type"): cv.string,
|
||||
vol.Remove("metadata"): dict, # Ignore metadata if present
|
||||
}
|
||||
),
|
||||
# Extract media_content_id from media selector format
|
||||
lambda x: x["media_content_id"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,7 +14,9 @@ announce:
|
||||
media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -23,7 +25,9 @@ announce:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
start_conversation:
|
||||
target:
|
||||
entity:
|
||||
@@ -40,7 +44,9 @@ start_conversation:
|
||||
start_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
extra_system_prompt:
|
||||
required: false
|
||||
selector:
|
||||
@@ -53,7 +59,9 @@ start_conversation:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
ask_question:
|
||||
fields:
|
||||
entity_id:
|
||||
@@ -72,7 +80,9 @@ ask_question:
|
||||
question_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -81,8 +91,24 @@ ask_question:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
answers:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
label_field: sentences
|
||||
description_field: id
|
||||
multiple: true
|
||||
translation_key: answers
|
||||
fields:
|
||||
id:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
sentences:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
@@ -90,5 +90,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"answers": {
|
||||
"fields": {
|
||||
"id": "Answer ID",
|
||||
"sentences": "Sentences"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.1",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.49.0"
|
||||
]
|
||||
|
||||
@@ -168,7 +168,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
key="windazimuth",
|
||||
translation_key="windazimuth",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
icon="mdi:compass-outline",
|
||||
device_class=SensorDeviceClass.WIND_DIRECTION,
|
||||
state_class=SensorStateClass.MEASUREMENT_ANGLE,
|
||||
),
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.103.0"],
|
||||
"requirements": ["hass-nabucasa==0.104.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -336,13 +336,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
"" if unit is None else unit
|
||||
)
|
||||
|
||||
# filter out all derivatives older than `time_window` from our window list
|
||||
self._state_list = [
|
||||
(time_start, time_end, state)
|
||||
for time_start, time_end, state in self._state_list
|
||||
if (new_state.last_reported - time_end).total_seconds()
|
||||
< self._time_window
|
||||
]
|
||||
self._prune_state_list(new_state.last_reported)
|
||||
|
||||
try:
|
||||
elapsed_time = (
|
||||
@@ -380,25 +374,14 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
||||
(old_last_reported, new_state.last_reported, new_derivative)
|
||||
)
|
||||
|
||||
def calculate_weight(
|
||||
start: datetime, end: datetime, now: datetime
|
||||
) -> float:
|
||||
window_start = now - timedelta(seconds=self._time_window)
|
||||
if start < window_start:
|
||||
weight = (end - window_start).total_seconds() / self._time_window
|
||||
else:
|
||||
weight = (end - start).total_seconds() / self._time_window
|
||||
return weight
|
||||
|
||||
# If outside of time window just report derivative (is the same as modeling it in the window),
|
||||
# otherwise take the weighted average with the previous derivatives
|
||||
if elapsed_time > self._time_window:
|
||||
derivative = new_derivative
|
||||
else:
|
||||
derivative = Decimal("0.00")
|
||||
for start, end, value in self._state_list:
|
||||
weight = calculate_weight(start, end, new_state.last_reported)
|
||||
derivative = derivative + (value * Decimal(weight))
|
||||
derivative = self._calc_derivative_from_state_list(
|
||||
new_state.last_reported
|
||||
)
|
||||
self._attr_native_value = round(derivative, self._round_digits)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from homeassistant.const import CONF_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
ConditionCheckerType,
|
||||
trace_condition_function,
|
||||
)
|
||||
@@ -51,20 +52,38 @@ class DeviceAutomationConditionProtocol(Protocol):
|
||||
"""List conditions."""
|
||||
|
||||
|
||||
async def async_validate_condition_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate device condition config."""
|
||||
return await async_validate_device_automation_config(
|
||||
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
|
||||
)
|
||||
class DeviceCondition(Condition):
|
||||
"""Device condition."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConfigType) -> None:
|
||||
"""Initialize condition."""
|
||||
self._config = config
|
||||
self._hass = hass
|
||||
|
||||
@classmethod
|
||||
async def async_validate_condition_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate device condition config."""
|
||||
return await async_validate_device_automation_config(
|
||||
hass, config, cv.DEVICE_CONDITION_SCHEMA, DeviceAutomationType.CONDITION
|
||||
)
|
||||
|
||||
async def async_condition_from_config(self) -> condition.ConditionCheckerType:
|
||||
"""Test a device condition."""
|
||||
platform = await async_get_device_automation_platform(
|
||||
self._hass, self._config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||
)
|
||||
return trace_condition_function(
|
||||
platform.async_condition_from_config(self._hass, self._config)
|
||||
)
|
||||
|
||||
|
||||
async def async_condition_from_config(
|
||||
hass: HomeAssistant, config: ConfigType
|
||||
) -> condition.ConditionCheckerType:
|
||||
"""Test a device condition."""
|
||||
platform = await async_get_device_automation_platform(
|
||||
hass, config[CONF_DOMAIN], DeviceAutomationType.CONDITION
|
||||
)
|
||||
return trace_condition_function(platform.async_condition_from_config(hass, config))
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"device": DeviceCondition,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the sun conditions."""
|
||||
return CONDITIONS
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeControlConfigEntry
|
||||
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
|
||||
from .entity import DevoloMultiLevelSwitchDeviceEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeControlConfigEntry
|
||||
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
|
||||
from .entity import DevoloMultiLevelSwitchDeviceEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Base class for multi level switches in devolo Home Control."""
|
||||
|
||||
from devolo_home_control_api.devices.zwave import Zwave
|
||||
from devolo_home_control_api.homecontrol import HomeControl
|
||||
|
||||
from .entity import DevoloDeviceEntity
|
||||
|
||||
|
||||
class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity):
|
||||
"""Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
|
||||
) -> None:
|
||||
"""Initialize a multi level switch within devolo Home Control."""
|
||||
super().__init__(
|
||||
homecontrol=homecontrol,
|
||||
device_instance=device_instance,
|
||||
element_uid=element_uid,
|
||||
)
|
||||
self._multi_level_switch_property = device_instance.multi_level_switch_property[
|
||||
element_uid
|
||||
]
|
||||
|
||||
self._value = self._multi_level_switch_property.value
|
||||
@@ -90,3 +90,24 @@ class DevoloDeviceEntity(Entity):
|
||||
self._attr_available = self._device_instance.is_online()
|
||||
else:
|
||||
_LOGGER.debug("No valid message received: %s", message)
|
||||
|
||||
|
||||
class DevoloMultiLevelSwitchDeviceEntity(DevoloDeviceEntity):
|
||||
"""Representation of a multi level switch device within devolo Home Control. Something like a dimmer or a thermostat."""
|
||||
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str
|
||||
) -> None:
|
||||
"""Initialize a multi level switch within devolo Home Control."""
|
||||
super().__init__(
|
||||
homecontrol=homecontrol,
|
||||
device_instance=device_instance,
|
||||
element_uid=element_uid,
|
||||
)
|
||||
self._multi_level_switch_property = device_instance.multi_level_switch_property[
|
||||
element_uid
|
||||
]
|
||||
|
||||
self._value = self._multi_level_switch_property.value
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeControlConfigEntry
|
||||
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
|
||||
from .entity import DevoloMultiLevelSwitchDeviceEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import DevoloHomeControlConfigEntry
|
||||
from .devolo_multi_level_switch import DevoloMultiLevelSwitchDeviceEntity
|
||||
from .entity import DevoloMultiLevelSwitchDeviceEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
"password": "Password of your mydevolo account."
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::devolo_home_control::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::devolo_home_control::config::step::user::data_description::password%]"
|
||||
}
|
||||
},
|
||||
"zeroconf_confirm": {
|
||||
"data": {
|
||||
"username": "[%key:component::devolo_home_control::config::step::user::data::username%]",
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyW215"],
|
||||
"requirements": ["pyW215==0.7.0"]
|
||||
"requirements": ["pyW215==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dormakaba_dkey",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["py-dormakaba-dkey==1.0.5"]
|
||||
"requirements": ["py-dormakaba-dkey==1.0.6"]
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [
|
||||
native_unit_of_measurement=UnitOfVolume.GALLONS,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda device: device.drop_api.water_used_today(),
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
DROPSensorEntityDescription(
|
||||
key=AVERAGE_WATER_USED,
|
||||
|
||||
@@ -241,6 +241,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="SHORT_POWER_FAILURE_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -249,6 +250,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="LONG_POWER_FAILURE_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -257,6 +259,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="VOLTAGE_SAG_L1_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -265,6 +268,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="VOLTAGE_SAG_L2_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -273,6 +277,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="VOLTAGE_SAG_L3_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -281,6 +286,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="VOLTAGE_SWELL_L1_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -289,6 +295,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="VOLTAGE_SWELL_L2_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
@@ -297,6 +304,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = (
|
||||
obis_reference="VOLTAGE_SWELL_L3_COUNT",
|
||||
dsmr_versions={"2.2", "4", "5", "5L"},
|
||||
entity_registry_enabled_default=False,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
DSMRSensorEntityDescription(
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Support for sending data to Dweet.io."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import dweepy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_FRIENDLY_NAME,
|
||||
CONF_NAME,
|
||||
CONF_WHITELIST,
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, state as state_helper
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "dweet"
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
DOMAIN: vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Required(CONF_WHITELIST, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.entity_id]
|
||||
),
|
||||
}
|
||||
)
|
||||
},
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Dweet.io component."""
|
||||
conf = config[DOMAIN]
|
||||
name = conf.get(CONF_NAME)
|
||||
whitelist = conf.get(CONF_WHITELIST)
|
||||
json_body = {}
|
||||
|
||||
def dweet_event_listener(event):
|
||||
"""Listen for new messages on the bus and sends them to Dweet.io."""
|
||||
state = event.data.get("new_state")
|
||||
if (
|
||||
state is None
|
||||
or state.state in (STATE_UNKNOWN, "")
|
||||
or state.entity_id not in whitelist
|
||||
):
|
||||
return
|
||||
|
||||
try:
|
||||
_state = state_helper.state_as_number(state)
|
||||
except ValueError:
|
||||
_state = state.state
|
||||
|
||||
json_body[state.attributes.get(ATTR_FRIENDLY_NAME)] = _state
|
||||
|
||||
send_data(name, json_body)
|
||||
|
||||
hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def send_data(name, msg):
|
||||
"""Send the collected data to Dweet.io."""
|
||||
try:
|
||||
dweepy.dweet_for(name, msg)
|
||||
except dweepy.DweepyError:
|
||||
_LOGGER.error("Error saving data to Dweet.io: %s", msg)
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"domain": "dweet",
|
||||
"name": "dweet.io",
|
||||
"codeowners": [],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dweet",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["dweepy"],
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["dweepy==0.3.0"]
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
"""Support for showing values from Dweet.io."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import logging
|
||||
|
||||
import dweepy
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorEntity,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE,
|
||||
CONF_NAME,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = "Dweet.io Sensor"
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
|
||||
{
|
||||
vol.Required(CONF_DEVICE): cv.string,
|
||||
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def setup_platform(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Dweet sensor."""
|
||||
name = config.get(CONF_NAME)
|
||||
device = config.get(CONF_DEVICE)
|
||||
value_template = config.get(CONF_VALUE_TEMPLATE)
|
||||
unit = config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
|
||||
try:
|
||||
content = json.dumps(dweepy.get_latest_dweet_for(device)[0]["content"])
|
||||
except dweepy.DweepyError:
|
||||
_LOGGER.error("Device/thing %s could not be found", device)
|
||||
return
|
||||
|
||||
if value_template and value_template.render_with_possible_json_value(content) == "":
|
||||
_LOGGER.error("%s was not found", value_template)
|
||||
return
|
||||
|
||||
dweet = DweetData(device)
|
||||
|
||||
add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True)
|
||||
|
||||
|
||||
class DweetSensor(SensorEntity):
|
||||
"""Representation of a Dweet sensor."""
|
||||
|
||||
def __init__(self, hass, dweet, name, value_template, unit_of_measurement):
|
||||
"""Initialize the sensor."""
|
||||
self.hass = hass
|
||||
self.dweet = dweet
|
||||
self._name = name
|
||||
self._value_template = value_template
|
||||
self._state = None
|
||||
self._unit_of_measurement = unit_of_measurement
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit the value is expressed in."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state."""
|
||||
return self._state
|
||||
|
||||
def update(self) -> None:
|
||||
"""Get the latest data from REST API."""
|
||||
self.dweet.update()
|
||||
|
||||
if self.dweet.data is None:
|
||||
self._state = None
|
||||
else:
|
||||
values = json.dumps(self.dweet.data[0]["content"])
|
||||
self._state = self._value_template.render_with_possible_json_value(
|
||||
values, None
|
||||
)
|
||||
|
||||
|
||||
class DweetData:
|
||||
"""The class for handling the data retrieval."""
|
||||
|
||||
def __init__(self, device):
|
||||
"""Initialize the sensor."""
|
||||
self._device = device
|
||||
self.data = None
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data from Dweet.io."""
|
||||
try:
|
||||
self.data = dweepy.get_latest_dweet_for(self._device)
|
||||
except dweepy.DweepyError:
|
||||
_LOGGER.warning("Device %s doesn't contain any data", self._device)
|
||||
self.data = None
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
|
||||
}
|
||||
|
||||
@@ -284,11 +284,15 @@ class EsphomeAssistSatellite(
|
||||
assert event.data is not None
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
|
||||
data_to_send = {
|
||||
"tts_start_streaming": "1"
|
||||
if (event.data and event.data.get("tts_start_streaming"))
|
||||
else "0",
|
||||
}
|
||||
if (
|
||||
not event.data
|
||||
or ("tts_start_streaming" not in event.data)
|
||||
or (not event.data["tts_start_streaming"])
|
||||
):
|
||||
# ESPHome only needs to know if early TTS streaming is available
|
||||
return
|
||||
|
||||
data_to_send = {"tts_start_streaming": "1"}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
assert event.data is not None
|
||||
data_to_send = {
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
import functools
|
||||
import logging
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Concatenate, Generic, TypeVar, cast
|
||||
|
||||
@@ -13,7 +14,6 @@ from aioesphomeapi import (
|
||||
EntityCategory as EsphomeEntityCategory,
|
||||
EntityInfo,
|
||||
EntityState,
|
||||
build_unique_id,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -32,9 +33,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from .const import DOMAIN
|
||||
|
||||
# Import config flow so that it's added to the registry
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData
|
||||
from .entry_data import ESPHomeConfigEntry, RuntimeEntryData, build_device_unique_id
|
||||
from .enum_mapper import EsphomeEnumMapper
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_InfoT = TypeVar("_InfoT", bound=EntityInfo)
|
||||
_EntityT = TypeVar("_EntityT", bound="EsphomeEntity[Any,Any]")
|
||||
_StateT = TypeVar("_StateT", bound=EntityState)
|
||||
@@ -53,21 +56,74 @@ def async_static_info_updated(
|
||||
) -> None:
|
||||
"""Update entities of this platform when entities are listed."""
|
||||
current_infos = entry_data.info[info_type]
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
new_infos: dict[int, EntityInfo] = {}
|
||||
add_entities: list[_EntityT] = []
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
dev_reg = dr.async_get(hass)
|
||||
|
||||
for info in infos:
|
||||
if not current_infos.pop(info.key, None):
|
||||
# Create new entity
|
||||
new_infos[info.key] = info
|
||||
|
||||
# Create new entity if it doesn't exist
|
||||
if not (old_info := current_infos.pop(info.key, None)):
|
||||
entity = entity_type(entry_data, platform.domain, info, state_type)
|
||||
add_entities.append(entity)
|
||||
new_infos[info.key] = info
|
||||
continue
|
||||
|
||||
# Entity exists - check if device_id has changed
|
||||
if old_info.device_id == info.device_id:
|
||||
continue
|
||||
|
||||
# Entity has switched devices, need to migrate unique_id
|
||||
old_unique_id = build_device_unique_id(device_info.mac_address, old_info)
|
||||
entity_id = ent_reg.async_get_entity_id(platform.domain, DOMAIN, old_unique_id)
|
||||
|
||||
# If entity not found in registry, re-add it
|
||||
# This happens when the device_id changed and the old device was deleted
|
||||
if entity_id is None:
|
||||
_LOGGER.info(
|
||||
"Entity with old unique_id %s not found in registry after device_id "
|
||||
"changed from %s to %s, re-adding entity",
|
||||
old_unique_id,
|
||||
old_info.device_id,
|
||||
info.device_id,
|
||||
)
|
||||
entity = entity_type(entry_data, platform.domain, info, state_type)
|
||||
add_entities.append(entity)
|
||||
continue
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
new_unique_id = build_device_unique_id(device_info.mac_address, info)
|
||||
|
||||
# Update unique_id if it changed
|
||||
if old_unique_id != new_unique_id:
|
||||
updates["new_unique_id"] = new_unique_id
|
||||
|
||||
# Update device assignment
|
||||
if info.device_id:
|
||||
# Entity now belongs to a sub device
|
||||
new_device = dev_reg.async_get_device(
|
||||
identifiers={(DOMAIN, f"{device_info.mac_address}_{info.device_id}")}
|
||||
)
|
||||
else:
|
||||
# Entity now belongs to the main device
|
||||
new_device = dev_reg.async_get_device(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
|
||||
if new_device:
|
||||
updates["device_id"] = new_device.id
|
||||
|
||||
# Apply all updates at once
|
||||
if updates:
|
||||
ent_reg.async_update_entity(entity_id, **updates)
|
||||
|
||||
# Anything still in current_infos is now gone
|
||||
if current_infos:
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
entry_data.async_remove_entities(
|
||||
hass, current_infos.values(), device_info.mac_address
|
||||
)
|
||||
@@ -225,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
|
||||
_static_info: _InfoT
|
||||
_state: _StateT
|
||||
_has_state: bool
|
||||
_has_state: bool = False
|
||||
unique_id: str
|
||||
|
||||
def __init__(
|
||||
@@ -244,11 +300,28 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
self._key = entity_info.key
|
||||
self._state_type = state_type
|
||||
self._on_static_info_update(entity_info)
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
|
||||
device_name = device_info.name
|
||||
# Determine the device connection based on whether this entity belongs to a sub device
|
||||
if entity_info.device_id:
|
||||
# Entity belongs to a sub device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(DOMAIN, f"{device_info.mac_address}_{entity_info.device_id}")
|
||||
}
|
||||
)
|
||||
# Use the pre-computed device_id_to_name mapping for O(1) lookup
|
||||
device_name = entry_data.device_id_to_name.get(
|
||||
entity_info.device_id, device_info.name
|
||||
)
|
||||
else:
|
||||
# Entity belongs to the main device
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device_info.mac_address)}
|
||||
)
|
||||
|
||||
if entity_info.name:
|
||||
self.entity_id = f"{domain}.{device_info.name}_{entity_info.object_id}"
|
||||
self.entity_id = f"{domain}.{device_name}_{entity_info.name}"
|
||||
else:
|
||||
# https://github.com/home-assistant/core/issues/132532
|
||||
# If name is not set, ESPHome will use the sanitized friendly name
|
||||
@@ -256,7 +329,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
# as the entity_id before it is sanitized since the sanitizer
|
||||
# is not utf-8 aware. In this case, its always going to be
|
||||
# an empty string so we drop the object_id.
|
||||
self.entity_id = f"{domain}.{device_info.name}"
|
||||
self.entity_id = f"{domain}.{device_name}"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -290,7 +363,9 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
static_info = cast(_InfoT, static_info)
|
||||
assert device_info
|
||||
self._static_info = static_info
|
||||
self._attr_unique_id = build_unique_id(device_info.mac_address, static_info)
|
||||
self._attr_unique_id = build_device_unique_id(
|
||||
device_info.mac_address, static_info
|
||||
)
|
||||
self._attr_entity_registry_enabled_default = not static_info.disabled_by_default
|
||||
# https://github.com/home-assistant/core/issues/132532
|
||||
# If the name is "", we need to set it to None since otherwise
|
||||
|
||||
@@ -95,6 +95,22 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
}
|
||||
|
||||
|
||||
def build_device_unique_id(mac: str, entity_info: EntityInfo) -> str:
|
||||
"""Build unique ID for entity, appending @device_id if it belongs to a sub-device.
|
||||
|
||||
This wrapper around build_unique_id ensures that entities belonging to sub-devices
|
||||
have their device_id appended to the unique_id to handle proper migration when
|
||||
entities move between devices.
|
||||
"""
|
||||
base_unique_id = build_unique_id(mac, entity_info)
|
||||
|
||||
# If entity belongs to a sub-device, append @device_id
|
||||
if entity_info.device_id:
|
||||
return f"{base_unique_id}@{entity_info.device_id}"
|
||||
|
||||
return base_unique_id
|
||||
|
||||
|
||||
class StoreData(TypedDict, total=False):
|
||||
"""ESPHome storage data."""
|
||||
|
||||
@@ -160,6 +176,7 @@ class RuntimeEntryData:
|
||||
assist_satellite_set_wake_word_callbacks: list[Callable[[str], None]] = field(
|
||||
default_factory=list
|
||||
)
|
||||
device_id_to_name: dict[int, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -222,7 +239,9 @@ class RuntimeEntryData:
|
||||
ent_reg = er.async_get(hass)
|
||||
for info in static_infos:
|
||||
if entry := ent_reg.async_get_entity_id(
|
||||
INFO_TYPE_TO_PLATFORM[type(info)], DOMAIN, build_unique_id(mac, info)
|
||||
INFO_TYPE_TO_PLATFORM[type(info)],
|
||||
DOMAIN,
|
||||
build_device_unique_id(mac, info),
|
||||
):
|
||||
ent_reg.async_remove(entry)
|
||||
|
||||
@@ -278,7 +297,8 @@ class RuntimeEntryData:
|
||||
if (
|
||||
(old_unique_id := info.unique_id)
|
||||
and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id))
|
||||
and (new_unique_id := build_unique_id(mac, info)) != old_unique_id
|
||||
and (new_unique_id := build_device_unique_id(mac, info))
|
||||
!= old_unique_id
|
||||
and not registry_get_entity(platform, DOMAIN, new_unique_id)
|
||||
):
|
||||
ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id)
|
||||
|
||||
@@ -527,6 +527,11 @@ class ESPHomeManager:
|
||||
device_info.name,
|
||||
device_mac,
|
||||
)
|
||||
# Build device_id_to_name mapping for efficient lookup
|
||||
entry_data.device_id_to_name = {
|
||||
sub_device.device_id: sub_device.name or device_info.name
|
||||
for sub_device in device_info.devices
|
||||
}
|
||||
self.device_id = _async_setup_device_registry(hass, entry, entry_data)
|
||||
|
||||
entry_data.async_update_device_state()
|
||||
@@ -751,6 +756,28 @@ def _async_setup_device_registry(
|
||||
device_info = entry_data.device_info
|
||||
if TYPE_CHECKING:
|
||||
assert device_info is not None
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
# Build sets of valid device identifiers and connections
|
||||
valid_connections = {
|
||||
(dr.CONNECTION_NETWORK_MAC, format_mac(device_info.mac_address))
|
||||
}
|
||||
valid_identifiers = {
|
||||
(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")
|
||||
for sub_device in device_info.devices
|
||||
}
|
||||
|
||||
# Remove devices that no longer exist
|
||||
for device in dr.async_entries_for_config_entry(device_registry, entry.entry_id):
|
||||
# Skip devices we want to keep
|
||||
if (
|
||||
device.connections & valid_connections
|
||||
or device.identifiers & valid_identifiers
|
||||
):
|
||||
continue
|
||||
# Remove everything else
|
||||
device_registry.async_remove_device(device.id)
|
||||
|
||||
sw_version = device_info.esphome_version
|
||||
if device_info.compilation_time:
|
||||
sw_version += f" ({device_info.compilation_time})"
|
||||
@@ -779,11 +806,14 @@ def _async_setup_device_registry(
|
||||
f"{device_info.project_version} (ESPHome {device_info.esphome_version})"
|
||||
)
|
||||
|
||||
suggested_area = None
|
||||
if device_info.suggested_area:
|
||||
suggested_area: str | None = None
|
||||
if device_info.area and device_info.area.name:
|
||||
# Prefer device_info.area over suggested_area when area name is not empty
|
||||
suggested_area = device_info.area.name
|
||||
elif device_info.suggested_area:
|
||||
suggested_area = device_info.suggested_area
|
||||
|
||||
device_registry = dr.async_get(hass)
|
||||
# Create/update main device
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
configuration_url=configuration_url,
|
||||
@@ -794,6 +824,36 @@ def _async_setup_device_registry(
|
||||
sw_version=sw_version,
|
||||
suggested_area=suggested_area,
|
||||
)
|
||||
|
||||
# Handle sub devices
|
||||
# Find available areas from device_info
|
||||
areas_by_id = {area.area_id: area for area in device_info.areas}
|
||||
# Add the main device's area if it exists
|
||||
if device_info.area:
|
||||
areas_by_id[device_info.area.area_id] = device_info.area
|
||||
# Create/update sub devices that should exist
|
||||
for sub_device in device_info.devices:
|
||||
# Determine the area for this sub device
|
||||
sub_device_suggested_area: str | None = None
|
||||
if sub_device.area_id is not None and sub_device.area_id in areas_by_id:
|
||||
sub_device_suggested_area = areas_by_id[sub_device.area_id].name
|
||||
|
||||
sub_device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, f"{device_info.mac_address}_{sub_device.device_id}")},
|
||||
name=sub_device.name or device_entry.name,
|
||||
manufacturer=manufacturer,
|
||||
model=model,
|
||||
sw_version=sw_version,
|
||||
suggested_area=sub_device_suggested_area,
|
||||
)
|
||||
|
||||
# Update the sub device to set via_device_id
|
||||
device_registry.async_update_device(
|
||||
sub_device_entry.id,
|
||||
via_device_id=device_entry.id,
|
||||
)
|
||||
|
||||
return device_entry.id
|
||||
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
# if the string is empty
|
||||
if unit_of_measurement := static_info.unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
self._attr_suggested_display_precision = static_info.accuracy_decimals
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, static_info.device_class
|
||||
)
|
||||
@@ -97,7 +98,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
self._attr_state_class = _STATE_CLASSES.from_esphome(state_class)
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | str | None:
|
||||
def native_value(self) -> datetime | int | float | None:
|
||||
"""Return the state of the entity."""
|
||||
if not self._has_state or (state := self._state).missing_state:
|
||||
return None
|
||||
@@ -106,7 +107,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
return None
|
||||
if self.device_class is SensorDeviceClass.TIMESTAMP:
|
||||
return dt_util.utc_from_timestamp(state_float)
|
||||
return f"{state_float:.{self._static_info.accuracy_decimals}f}"
|
||||
return state_float
|
||||
|
||||
|
||||
class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity):
|
||||
|
||||
@@ -2,9 +2,17 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from pyezvizapi.constants import DeviceSwitchType, SoundMode
|
||||
from pyezvizapi.constants import (
|
||||
BatteryCameraWorkMode,
|
||||
DeviceCatagories,
|
||||
DeviceSwitchType,
|
||||
SoundMode,
|
||||
SupportExt,
|
||||
)
|
||||
from pyezvizapi.exceptions import HTTPError, PyEzvizError
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
@@ -24,17 +32,83 @@ class EzvizSelectEntityDescription(SelectEntityDescription):
|
||||
"""Describe a EZVIZ Select entity."""
|
||||
|
||||
supported_switch: int
|
||||
current_option: Callable[[EzvizSelect], str | None]
|
||||
select_option: Callable[[EzvizSelect, str, str], None]
|
||||
|
||||
|
||||
SELECT_TYPE = EzvizSelectEntityDescription(
|
||||
def alarm_sound_mode_current_option(ezvizSelect: EzvizSelect) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
sound_mode_value = getattr(
|
||||
SoundMode, ezvizSelect.data[ezvizSelect.entity_description.key]
|
||||
).value
|
||||
if sound_mode_value in [0, 1, 2]:
|
||||
return ezvizSelect.options[sound_mode_value]
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def alarm_sound_mode_select_option(
|
||||
ezvizSelect: EzvizSelect, serial: str, option: str
|
||||
) -> None:
|
||||
"""Change the selected option."""
|
||||
sound_mode_value = ezvizSelect.options.index(option)
|
||||
ezvizSelect.coordinator.ezviz_client.alarm_sound(serial, sound_mode_value, 1)
|
||||
|
||||
|
||||
ALARM_SOUND_MODE_SELECT_TYPE = EzvizSelectEntityDescription(
|
||||
key="alarm_sound_mod",
|
||||
translation_key="alarm_sound_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options=["soft", "intensive", "silent"],
|
||||
supported_switch=DeviceSwitchType.ALARM_TONE.value,
|
||||
current_option=alarm_sound_mode_current_option,
|
||||
select_option=alarm_sound_mode_select_option,
|
||||
)
|
||||
|
||||
|
||||
def battery_work_mode_current_option(ezvizSelect: EzvizSelect) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
battery_work_mode = getattr(
|
||||
BatteryCameraWorkMode,
|
||||
ezvizSelect.data[ezvizSelect.entity_description.key],
|
||||
BatteryCameraWorkMode.UNKNOWN,
|
||||
)
|
||||
if battery_work_mode == BatteryCameraWorkMode.UNKNOWN:
|
||||
return None
|
||||
|
||||
return battery_work_mode.name.lower()
|
||||
|
||||
|
||||
def battery_work_mode_select_option(
|
||||
ezvizSelect: EzvizSelect, serial: str, option: str
|
||||
) -> None:
|
||||
"""Change the selected option."""
|
||||
battery_work_mode = getattr(BatteryCameraWorkMode, option.upper())
|
||||
ezvizSelect.coordinator.ezviz_client.set_battery_camera_work_mode(
|
||||
serial, battery_work_mode.value
|
||||
)
|
||||
|
||||
|
||||
BATTERY_WORK_MODE_SELECT_TYPE = EzvizSelectEntityDescription(
|
||||
key="battery_camera_work_mode",
|
||||
translation_key="battery_camera_work_mode",
|
||||
icon="mdi:battery-sync",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options=[
|
||||
"plugged_in",
|
||||
"high_performance",
|
||||
"power_save",
|
||||
"super_power_save",
|
||||
"custom",
|
||||
],
|
||||
supported_switch=-1,
|
||||
current_option=battery_work_mode_current_option,
|
||||
select_option=battery_work_mode_select_option,
|
||||
)
|
||||
|
||||
SELECT_TYPES = [ALARM_SOUND_MODE_SELECT_TYPE, BATTERY_WORK_MODE_SELECT_TYPE]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: EzvizConfigEntry,
|
||||
@@ -43,12 +117,26 @@ async def async_setup_entry(
|
||||
"""Set up EZVIZ select entities based on a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
EzvizSelect(coordinator, camera)
|
||||
entities = [
|
||||
EzvizSelect(coordinator, camera, ALARM_SOUND_MODE_SELECT_TYPE)
|
||||
for camera in coordinator.data
|
||||
for switch in coordinator.data[camera]["switches"]
|
||||
if switch == SELECT_TYPE.supported_switch
|
||||
)
|
||||
if switch == ALARM_SOUND_MODE_SELECT_TYPE.supported_switch
|
||||
]
|
||||
|
||||
for camera in coordinator.data:
|
||||
device_category = coordinator.data[camera].get("device_category")
|
||||
supportExt = coordinator.data[camera].get("supportExt")
|
||||
if (
|
||||
device_category == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value
|
||||
and supportExt
|
||||
and str(SupportExt.SupportBatteryManage.value) in supportExt
|
||||
):
|
||||
entities.append(
|
||||
EzvizSelect(coordinator, camera, BATTERY_WORK_MODE_SELECT_TYPE)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class EzvizSelect(EzvizEntity, SelectEntity):
|
||||
@@ -58,31 +146,23 @@ class EzvizSelect(EzvizEntity, SelectEntity):
|
||||
self,
|
||||
coordinator: EzvizDataUpdateCoordinator,
|
||||
serial: str,
|
||||
description: EzvizSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
"""Initialize the select entity."""
|
||||
super().__init__(coordinator, serial)
|
||||
self._attr_unique_id = f"{serial}_{SELECT_TYPE.key}"
|
||||
self.entity_description = SELECT_TYPE
|
||||
self._attr_unique_id = f"{serial}_{description.key}"
|
||||
self.entity_description = description
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the selected entity option to represent the entity state."""
|
||||
sound_mode_value = getattr(
|
||||
SoundMode, self.data[self.entity_description.key]
|
||||
).value
|
||||
if sound_mode_value in [0, 1, 2]:
|
||||
return self.options[sound_mode_value]
|
||||
|
||||
return None
|
||||
desc = cast(EzvizSelectEntityDescription, self.entity_description)
|
||||
return desc.current_option(self)
|
||||
|
||||
def select_option(self, option: str) -> None:
|
||||
"""Change the selected option."""
|
||||
sound_mode_value = self.options.index(option)
|
||||
|
||||
desc = cast(EzvizSelectEntityDescription, self.entity_description)
|
||||
try:
|
||||
self.coordinator.ezviz_client.alarm_sound(self._serial, sound_mode_value, 1)
|
||||
|
||||
return desc.select_option(self, self._serial, option)
|
||||
except (HTTPError, PyEzvizError) as err:
|
||||
raise HomeAssistantError(
|
||||
f"Cannot set Warning sound level for {self.entity_id}"
|
||||
) from err
|
||||
raise HomeAssistantError(f"Cannot select option for {desc.key}") from err
|
||||
|
||||
@@ -68,6 +68,16 @@
|
||||
"intensive": "Intensive",
|
||||
"silent": "Silent"
|
||||
}
|
||||
},
|
||||
"battery_camera_work_mode": {
|
||||
"name": "Battery work mode",
|
||||
"state": {
|
||||
"plugged_in": "Plugged in",
|
||||
"high_performance": "High performance",
|
||||
"power_save": "Power save",
|
||||
"super_power_save": "Super power saving",
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"image": {
|
||||
|
||||
@@ -15,8 +15,9 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ConnectionInfo, FritzConfigEntry
|
||||
from .coordinator import FritzConfigEntry
|
||||
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
|
||||
from .models import ConnectionInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -19,15 +19,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import BUTTON_TYPE_WOL, CONNECTION_TYPE_LAN, MeshRoles
|
||||
from .coordinator import (
|
||||
FRITZ_DATA_KEY,
|
||||
AvmWrapper,
|
||||
FritzConfigEntry,
|
||||
FritzData,
|
||||
FritzDevice,
|
||||
_is_tracked,
|
||||
)
|
||||
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
|
||||
from .entity import FritzDeviceBase
|
||||
from .helpers import _is_tracked
|
||||
from .models import FritzDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping, ValuesView
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from functools import partial
|
||||
@@ -34,7 +34,6 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
@@ -48,6 +47,15 @@ from .const import (
|
||||
FRITZ_EXCEPTIONS,
|
||||
MeshRoles,
|
||||
)
|
||||
from .helpers import _ha_is_stopping
|
||||
from .models import (
|
||||
ConnectionInfo,
|
||||
Device,
|
||||
FritzDevice,
|
||||
HostAttributes,
|
||||
HostInfo,
|
||||
Interface,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -56,33 +64,13 @@ FRITZ_DATA_KEY: HassKey[FritzData] = HassKey(DOMAIN)
|
||||
type FritzConfigEntry = ConfigEntry[AvmWrapper]
|
||||
|
||||
|
||||
def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
|
||||
"""Check if device is already tracked."""
|
||||
return any(mac in tracked for tracked in current_devices)
|
||||
@dataclass
|
||||
class FritzData:
|
||||
"""Storage class for platform global data."""
|
||||
|
||||
|
||||
def device_filter_out_from_trackers(
|
||||
mac: str,
|
||||
device: FritzDevice,
|
||||
current_devices: ValuesView[set[str]],
|
||||
) -> bool:
|
||||
"""Check if device should be filtered out from trackers."""
|
||||
reason: str | None = None
|
||||
if device.ip_address == "":
|
||||
reason = "Missing IP"
|
||||
elif _is_tracked(mac, current_devices):
|
||||
reason = "Already tracked"
|
||||
|
||||
if reason:
|
||||
_LOGGER.debug(
|
||||
"Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
|
||||
)
|
||||
return bool(reason)
|
||||
|
||||
|
||||
def _ha_is_stopping(activity: str) -> None:
|
||||
"""Inform that HA is stopping."""
|
||||
_LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)
|
||||
tracked: dict[str, set[str]] = field(default_factory=dict)
|
||||
profile_switches: dict[str, set[str]] = field(default_factory=dict)
|
||||
wol_buttons: dict[str, set[str]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ClassSetupMissing(Exception):
|
||||
@@ -93,68 +81,6 @@ class ClassSetupMissing(Exception):
|
||||
super().__init__("Function called before Class setup")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""FRITZ!Box device class."""
|
||||
|
||||
connected: bool
|
||||
connected_to: str
|
||||
connection_type: str
|
||||
ip_address: str
|
||||
name: str
|
||||
ssid: str | None
|
||||
wan_access: bool | None = None
|
||||
|
||||
|
||||
class Interface(TypedDict):
|
||||
"""Interface details."""
|
||||
|
||||
device: str
|
||||
mac: str
|
||||
op_mode: str
|
||||
ssid: str | None
|
||||
type: str
|
||||
|
||||
|
||||
HostAttributes = TypedDict(
|
||||
"HostAttributes",
|
||||
{
|
||||
"Index": int,
|
||||
"IPAddress": str,
|
||||
"MACAddress": str,
|
||||
"Active": bool,
|
||||
"HostName": str,
|
||||
"InterfaceType": str,
|
||||
"X_AVM-DE_Port": int,
|
||||
"X_AVM-DE_Speed": int,
|
||||
"X_AVM-DE_UpdateAvailable": bool,
|
||||
"X_AVM-DE_UpdateSuccessful": str,
|
||||
"X_AVM-DE_InfoURL": str | None,
|
||||
"X_AVM-DE_MACAddressList": str | None,
|
||||
"X_AVM-DE_Model": str | None,
|
||||
"X_AVM-DE_URL": str | None,
|
||||
"X_AVM-DE_Guest": bool,
|
||||
"X_AVM-DE_RequestClient": str,
|
||||
"X_AVM-DE_VPN": bool,
|
||||
"X_AVM-DE_WANAccess": str,
|
||||
"X_AVM-DE_Disallow": bool,
|
||||
"X_AVM-DE_IsMeshable": str,
|
||||
"X_AVM-DE_Priority": str,
|
||||
"X_AVM-DE_FriendlyName": str,
|
||||
"X_AVM-DE_FriendlyNameIsWriteable": str,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class HostInfo(TypedDict):
|
||||
"""FRITZ!Box host info class."""
|
||||
|
||||
mac: str
|
||||
name: str
|
||||
ip: str
|
||||
status: bool
|
||||
|
||||
|
||||
class UpdateCoordinatorDataType(TypedDict):
|
||||
"""Update coordinator data type."""
|
||||
|
||||
@@ -898,120 +824,3 @@ class AvmWrapper(FritzBoxTools):
|
||||
"X_AVM-DE_WakeOnLANByMACAddress",
|
||||
NewMACAddress=mac_address,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FritzData:
|
||||
"""Storage class for platform global data."""
|
||||
|
||||
tracked: dict[str, set[str]] = field(default_factory=dict)
|
||||
profile_switches: dict[str, set[str]] = field(default_factory=dict)
|
||||
wol_buttons: dict[str, set[str]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FritzDevice:
|
||||
"""Representation of a device connected to the FRITZ!Box."""
|
||||
|
||||
def __init__(self, mac: str, name: str) -> None:
|
||||
"""Initialize device info."""
|
||||
self._connected = False
|
||||
self._connected_to: str | None = None
|
||||
self._connection_type: str | None = None
|
||||
self._ip_address: str | None = None
|
||||
self._last_activity: datetime | None = None
|
||||
self._mac = mac
|
||||
self._name = name
|
||||
self._ssid: str | None = None
|
||||
self._wan_access: bool | None = False
|
||||
|
||||
def update(self, dev_info: Device, consider_home: float) -> None:
|
||||
"""Update device info."""
|
||||
utc_point_in_time = dt_util.utcnow()
|
||||
|
||||
if self._last_activity:
|
||||
consider_home_evaluated = (
|
||||
utc_point_in_time - self._last_activity
|
||||
).total_seconds() < consider_home
|
||||
else:
|
||||
consider_home_evaluated = dev_info.connected
|
||||
|
||||
if not self._name:
|
||||
self._name = dev_info.name or self._mac.replace(":", "_")
|
||||
|
||||
self._connected = dev_info.connected or consider_home_evaluated
|
||||
|
||||
if dev_info.connected:
|
||||
self._last_activity = utc_point_in_time
|
||||
|
||||
self._connected_to = dev_info.connected_to
|
||||
self._connection_type = dev_info.connection_type
|
||||
self._ip_address = dev_info.ip_address
|
||||
self._ssid = dev_info.ssid
|
||||
self._wan_access = dev_info.wan_access
|
||||
|
||||
@property
|
||||
def connected_to(self) -> str | None:
|
||||
"""Return connected status."""
|
||||
return self._connected_to
|
||||
|
||||
@property
|
||||
def connection_type(self) -> str | None:
|
||||
"""Return connected status."""
|
||||
return self._connection_type
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return connected status."""
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def mac_address(self) -> str:
|
||||
"""Get MAC address."""
|
||||
return self._mac
|
||||
|
||||
@property
|
||||
def hostname(self) -> str:
|
||||
"""Get Name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Get IP address."""
|
||||
return self._ip_address
|
||||
|
||||
@property
|
||||
def last_activity(self) -> datetime | None:
|
||||
"""Return device last activity."""
|
||||
return self._last_activity
|
||||
|
||||
@property
|
||||
def ssid(self) -> str | None:
|
||||
"""Return device connected SSID."""
|
||||
return self._ssid
|
||||
|
||||
@property
|
||||
def wan_access(self) -> bool | None:
|
||||
"""Return device wan access."""
|
||||
return self._wan_access
|
||||
|
||||
|
||||
class SwitchInfo(TypedDict):
|
||||
"""FRITZ!Box switch info class."""
|
||||
|
||||
description: str
|
||||
friendly_name: str
|
||||
icon: str
|
||||
type: str
|
||||
callback_update: Callable
|
||||
callback_switch: Callable
|
||||
init_state: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
ipv6_active: bool
|
||||
|
||||
@@ -10,15 +10,10 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import (
|
||||
FRITZ_DATA_KEY,
|
||||
AvmWrapper,
|
||||
FritzConfigEntry,
|
||||
FritzData,
|
||||
FritzDevice,
|
||||
device_filter_out_from_trackers,
|
||||
)
|
||||
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
|
||||
from .entity import FritzDeviceBase
|
||||
from .helpers import device_filter_out_from_trackers
|
||||
from .models import FritzDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DEFAULT_DEVICE_NAME, DOMAIN
|
||||
from .coordinator import AvmWrapper, FritzDevice
|
||||
from .coordinator import AvmWrapper
|
||||
from .models import FritzDevice
|
||||
|
||||
|
||||
class FritzDeviceBase(CoordinatorEntity[AvmWrapper]):
|
||||
|
||||
39
homeassistant/components/fritz/helpers.py
Normal file
39
homeassistant/components/fritz/helpers.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Helpers for AVM FRITZ!Box."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import ValuesView
|
||||
import logging
|
||||
|
||||
from .models import FritzDevice
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _is_tracked(mac: str, current_devices: ValuesView[set[str]]) -> bool:
|
||||
"""Check if device is already tracked."""
|
||||
return any(mac in tracked for tracked in current_devices)
|
||||
|
||||
|
||||
def device_filter_out_from_trackers(
|
||||
mac: str,
|
||||
device: FritzDevice,
|
||||
current_devices: ValuesView[set[str]],
|
||||
) -> bool:
|
||||
"""Check if device should be filtered out from trackers."""
|
||||
reason: str | None = None
|
||||
if device.ip_address == "":
|
||||
reason = "Missing IP"
|
||||
elif _is_tracked(mac, current_devices):
|
||||
reason = "Already tracked"
|
||||
|
||||
if reason:
|
||||
_LOGGER.debug(
|
||||
"Skip adding device %s [%s], reason: %s", device.hostname, mac, reason
|
||||
)
|
||||
return bool(reason)
|
||||
|
||||
|
||||
def _ha_is_stopping(activity: str) -> None:
|
||||
"""Inform that HA is stopping."""
|
||||
_LOGGER.warning("Cannot execute %s: HomeAssistant is shutting down", activity)
|
||||
182
homeassistant/components/fritz/models.py
Normal file
182
homeassistant/components/fritz/models.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Models for AVM FRITZ!Box."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import TypedDict
|
||||
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import MeshRoles
|
||||
|
||||
|
||||
@dataclass
|
||||
class Device:
|
||||
"""FRITZ!Box device class."""
|
||||
|
||||
connected: bool
|
||||
connected_to: str
|
||||
connection_type: str
|
||||
ip_address: str
|
||||
name: str
|
||||
ssid: str | None
|
||||
wan_access: bool | None = None
|
||||
|
||||
|
||||
class Interface(TypedDict):
|
||||
"""Interface details."""
|
||||
|
||||
device: str
|
||||
mac: str
|
||||
op_mode: str
|
||||
ssid: str | None
|
||||
type: str
|
||||
|
||||
|
||||
HostAttributes = TypedDict(
|
||||
"HostAttributes",
|
||||
{
|
||||
"Index": int,
|
||||
"IPAddress": str,
|
||||
"MACAddress": str,
|
||||
"Active": bool,
|
||||
"HostName": str,
|
||||
"InterfaceType": str,
|
||||
"X_AVM-DE_Port": int,
|
||||
"X_AVM-DE_Speed": int,
|
||||
"X_AVM-DE_UpdateAvailable": bool,
|
||||
"X_AVM-DE_UpdateSuccessful": str,
|
||||
"X_AVM-DE_InfoURL": str | None,
|
||||
"X_AVM-DE_MACAddressList": str | None,
|
||||
"X_AVM-DE_Model": str | None,
|
||||
"X_AVM-DE_URL": str | None,
|
||||
"X_AVM-DE_Guest": bool,
|
||||
"X_AVM-DE_RequestClient": str,
|
||||
"X_AVM-DE_VPN": bool,
|
||||
"X_AVM-DE_WANAccess": str,
|
||||
"X_AVM-DE_Disallow": bool,
|
||||
"X_AVM-DE_IsMeshable": str,
|
||||
"X_AVM-DE_Priority": str,
|
||||
"X_AVM-DE_FriendlyName": str,
|
||||
"X_AVM-DE_FriendlyNameIsWriteable": str,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class HostInfo(TypedDict):
|
||||
"""FRITZ!Box host info class."""
|
||||
|
||||
mac: str
|
||||
name: str
|
||||
ip: str
|
||||
status: bool
|
||||
|
||||
|
||||
class FritzDevice:
|
||||
"""Representation of a device connected to the FRITZ!Box."""
|
||||
|
||||
def __init__(self, mac: str, name: str) -> None:
|
||||
"""Initialize device info."""
|
||||
self._connected = False
|
||||
self._connected_to: str | None = None
|
||||
self._connection_type: str | None = None
|
||||
self._ip_address: str | None = None
|
||||
self._last_activity: datetime | None = None
|
||||
self._mac = mac
|
||||
self._name = name
|
||||
self._ssid: str | None = None
|
||||
self._wan_access: bool | None = False
|
||||
|
||||
def update(self, dev_info: Device, consider_home: float) -> None:
|
||||
"""Update device info."""
|
||||
utc_point_in_time = dt_util.utcnow()
|
||||
|
||||
if self._last_activity:
|
||||
consider_home_evaluated = (
|
||||
utc_point_in_time - self._last_activity
|
||||
).total_seconds() < consider_home
|
||||
else:
|
||||
consider_home_evaluated = dev_info.connected
|
||||
|
||||
if not self._name:
|
||||
self._name = dev_info.name or self._mac.replace(":", "_")
|
||||
|
||||
self._connected = dev_info.connected or consider_home_evaluated
|
||||
|
||||
if dev_info.connected:
|
||||
self._last_activity = utc_point_in_time
|
||||
|
||||
self._connected_to = dev_info.connected_to
|
||||
self._connection_type = dev_info.connection_type
|
||||
self._ip_address = dev_info.ip_address
|
||||
self._ssid = dev_info.ssid
|
||||
self._wan_access = dev_info.wan_access
|
||||
|
||||
@property
|
||||
def connected_to(self) -> str | None:
|
||||
"""Return connected status."""
|
||||
return self._connected_to
|
||||
|
||||
@property
|
||||
def connection_type(self) -> str | None:
|
||||
"""Return connected status."""
|
||||
return self._connection_type
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
"""Return connected status."""
|
||||
return self._connected
|
||||
|
||||
@property
|
||||
def mac_address(self) -> str:
|
||||
"""Get MAC address."""
|
||||
return self._mac
|
||||
|
||||
@property
|
||||
def hostname(self) -> str:
|
||||
"""Get Name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def ip_address(self) -> str | None:
|
||||
"""Get IP address."""
|
||||
return self._ip_address
|
||||
|
||||
@property
|
||||
def last_activity(self) -> datetime | None:
|
||||
"""Return device last activity."""
|
||||
return self._last_activity
|
||||
|
||||
@property
|
||||
def ssid(self) -> str | None:
|
||||
"""Return device connected SSID."""
|
||||
return self._ssid
|
||||
|
||||
@property
|
||||
def wan_access(self) -> bool | None:
|
||||
"""Return device wan access."""
|
||||
return self._wan_access
|
||||
|
||||
|
||||
class SwitchInfo(TypedDict):
|
||||
"""FRITZ!Box switch info class."""
|
||||
|
||||
description: str
|
||||
friendly_name: str
|
||||
icon: str
|
||||
type: str
|
||||
callback_update: Callable
|
||||
callback_switch: Callable
|
||||
init_state: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionInfo:
|
||||
"""Fritz sensor connection information class."""
|
||||
|
||||
connection: str
|
||||
mesh_role: MeshRoles
|
||||
wan_enabled: bool
|
||||
ipv6_active: bool
|
||||
@@ -27,8 +27,9 @@ from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import DSL_CONNECTION, UPTIME_DEVIATION
|
||||
from .coordinator import ConnectionInfo, FritzConfigEntry
|
||||
from .coordinator import FritzConfigEntry
|
||||
from .entity import FritzBoxBaseCoordinatorEntity, FritzEntityDescription
|
||||
from .models import ConnectionInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -25,16 +25,10 @@ from .const import (
|
||||
WIFI_STANDARD,
|
||||
MeshRoles,
|
||||
)
|
||||
from .coordinator import (
|
||||
FRITZ_DATA_KEY,
|
||||
AvmWrapper,
|
||||
FritzConfigEntry,
|
||||
FritzData,
|
||||
FritzDevice,
|
||||
SwitchInfo,
|
||||
device_filter_out_from_trackers,
|
||||
)
|
||||
from .coordinator import FRITZ_DATA_KEY, AvmWrapper, FritzConfigEntry, FritzData
|
||||
from .entity import FritzBoxBaseEntity, FritzDeviceBase
|
||||
from .helpers import device_filter_out_from_trackers
|
||||
from .models import FritzDevice, SwitchInfo
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250531.4"]
|
||||
"requirements": ["home-assistant-frontend==20250702.0"]
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from google.genai import Client
|
||||
from google.genai.errors import APIError, ClientError
|
||||
@@ -35,12 +36,14 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_PROMPT,
|
||||
DEFAULT_TITLE,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
FILE_POLLING_INTERVAL_SECONDS,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
|
||||
@@ -190,7 +193,7 @@ async def async_setup_entry(
|
||||
|
||||
client = await hass.async_add_executor_job(_init_client)
|
||||
await client.aio.models.get(
|
||||
model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
model=RECOMMENDED_CHAT_MODEL,
|
||||
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
|
||||
)
|
||||
except (APIError, Timeout) as err:
|
||||
@@ -204,6 +207,8 @@ async def async_setup_entry(
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -217,6 +222,13 @@ async def async_unload_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -243,6 +255,16 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||
|
||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||
if use_existing:
|
||||
hass.config_entries.async_add_subentry(
|
||||
parent_entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
|
||||
subentry_type="tts",
|
||||
title=DEFAULT_TTS_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
conversation_entity = entity_registry.async_get_entity_id(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
@@ -271,12 +293,65 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
)
|
||||
else:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
if not use_existing:
|
||||
await hass.config_entries.async_remove(entry.entry_id)
|
||||
else:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
title=DEFAULT_TITLE,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Add TTS subentry which was missing in 2025.7.0b0
|
||||
if not any(
|
||||
subentry.subentry_type == "tts" for subentry in entry.subentries.values()
|
||||
):
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
|
||||
subentry_type="tts",
|
||||
title=DEFAULT_TTS_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -47,13 +47,18 @@ from .const import (
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DEFAULT_TITLE,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_K,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_TTS_MODEL,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
@@ -66,12 +71,6 @@ STEP_API_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
RECOMMENDED_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -93,6 +92,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Generative AI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -118,15 +118,21 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data=user_input,
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title="Google Generative AI",
|
||||
title=DEFAULT_TITLE,
|
||||
data=user_input,
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_OPTIONS,
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_type": "tts",
|
||||
"data": RECOMMENDED_TTS_OPTIONS,
|
||||
"title": DEFAULT_TTS_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
return self.async_show_form(
|
||||
@@ -172,10 +178,13 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"conversation": ConversationSubentryFlowHandler}
|
||||
return {
|
||||
"conversation": LLMSubentryFlowHandler,
|
||||
"tts": LLMSubentryFlowHandler,
|
||||
}
|
||||
|
||||
|
||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing conversation subentries."""
|
||||
|
||||
last_rendered_recommended = False
|
||||
@@ -202,7 +211,11 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
|
||||
if user_input is None:
|
||||
if self._is_new:
|
||||
options = RECOMMENDED_OPTIONS.copy()
|
||||
options: dict[str, Any]
|
||||
if self._subentry_type == "tts":
|
||||
options = RECOMMENDED_TTS_OPTIONS.copy()
|
||||
else:
|
||||
options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
else:
|
||||
# If this is a reconfiguration, we need to copy the existing options
|
||||
# so that we can show the current values in the form.
|
||||
@@ -216,7 +229,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
# Don't allow to save options that enable the Google Seearch tool with an Assist API
|
||||
# Don't allow to save options that enable the Google Search tool with an Assist API
|
||||
if not (
|
||||
user_input.get(CONF_LLM_HASS_API)
|
||||
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
|
||||
@@ -240,7 +253,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
options = user_input
|
||||
|
||||
schema = await google_generative_ai_config_option_schema(
|
||||
self.hass, self._is_new, options, self._genai_client
|
||||
self.hass, self._is_new, self._subentry_type, options, self._genai_client
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
|
||||
@@ -253,6 +266,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
async def google_generative_ai_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
is_new: bool,
|
||||
subentry_type: str,
|
||||
options: Mapping[str, Any],
|
||||
genai_client: genai.Client,
|
||||
) -> dict:
|
||||
@@ -270,26 +284,39 @@ async def google_generative_ai_config_option_schema(
|
||||
suggested_llm_apis = [suggested_llm_apis]
|
||||
|
||||
if is_new:
|
||||
if CONF_NAME in options:
|
||||
default_name = options[CONF_NAME]
|
||||
elif subentry_type == "tts":
|
||||
default_name = DEFAULT_TTS_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
||||
vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME): str,
|
||||
vol.Required(CONF_NAME, default=default_name): str,
|
||||
}
|
||||
else:
|
||||
schema = {}
|
||||
|
||||
if subentry_type == "conversation":
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": suggested_llm_apis},
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": suggested_llm_apis},
|
||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
@@ -303,14 +330,15 @@ async def google_generative_ai_config_option_schema(
|
||||
api_models = [api_model async for api_model in api_models_pager]
|
||||
models = [
|
||||
SelectOptionDict(
|
||||
label=api_model.display_name,
|
||||
label=api_model.name.lstrip("models/"),
|
||||
value=api_model.name,
|
||||
)
|
||||
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
|
||||
for api_model in sorted(
|
||||
api_models, key=lambda x: x.name.lstrip("models/") or ""
|
||||
)
|
||||
if (
|
||||
api_model.display_name
|
||||
and api_model.name
|
||||
and "tts" not in api_model.name
|
||||
api_model.name
|
||||
and ("tts" in api_model.name) == (subentry_type == "tts")
|
||||
and "vision" not in api_model.name
|
||||
and api_model.supported_actions
|
||||
and "generateContent" in api_model.supported_actions
|
||||
@@ -341,12 +369,17 @@ async def google_generative_ai_config_option_schema(
|
||||
)
|
||||
)
|
||||
|
||||
if subentry_type == "tts":
|
||||
default_model = RECOMMENDED_TTS_MODEL
|
||||
else:
|
||||
default_model = RECOMMENDED_CHAT_MODEL
|
||||
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CHAT_MODEL,
|
||||
description={"suggested_value": options.get(CONF_CHAT_MODEL)},
|
||||
default=RECOMMENDED_CHAT_MODEL,
|
||||
default=default_model,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(mode=SelectSelectorMode.DROPDOWN, options=models)
|
||||
),
|
||||
@@ -396,13 +429,18 @@ async def google_generative_ai_config_option_schema(
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
vol.Optional(
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
|
||||
},
|
||||
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
if subentry_type == "conversation":
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
|
||||
},
|
||||
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
return schema
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
DOMAIN = "google_generative_ai_conversation"
|
||||
DEFAULT_TITLE = "Google Generative AI"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
CONF_PROMPT = "prompt"
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
|
||||
DEFAULT_TTS_NAME = "Google AI TTS"
|
||||
|
||||
ATTR_MODEL = "model"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
RECOMMENDED_CHAT_MODEL = "models/gemini-2.5-flash"
|
||||
RECOMMENDED_TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||
RECOMMENDED_TTS_MODEL = "models/gemini-2.5-flash-preview-tts"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
RECOMMENDED_TEMPERATURE = 1.0
|
||||
CONF_TOP_P = "top_p"
|
||||
@@ -31,3 +35,12 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
|
||||
|
||||
TIMEOUT_MILLIS = 10000
|
||||
FILE_POLLING_INTERVAL_SECONDS = 0.05
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
RECOMMENDED_TTS_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
@@ -61,9 +61,6 @@ class GoogleGenerativeAIConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -103,10 +100,3 @@ class GoogleGenerativeAIConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -21,6 +21,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"title": entry.title,
|
||||
"data": entry.data,
|
||||
"options": entry.options,
|
||||
"subentries": dict(entry.subentries),
|
||||
},
|
||||
TO_REDACT,
|
||||
)
|
||||
|
||||
@@ -301,7 +301,12 @@ async def _transform_stream(
|
||||
class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
"""Google Generative AI base entity."""
|
||||
|
||||
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
subentry: ConfigSubentry,
|
||||
default_model: str = RECOMMENDED_CHAT_MODEL,
|
||||
) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
@@ -312,7 +317,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Google",
|
||||
model="Generative AI",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, default_model).split("/")[-1],
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@@ -337,7 +342,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
tools = tools or []
|
||||
tools.append(Tool(google_search=GoogleSearch()))
|
||||
|
||||
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
model_name = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
# Avoid INVALID_ARGUMENT Developer instruction is not enabled for <model>
|
||||
supports_system_instruction = (
|
||||
"gemma" not in model_name
|
||||
@@ -389,47 +394,13 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
generateContentConfig = GenerateContentConfig(
|
||||
temperature=self.entry.options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
),
|
||||
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
||||
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
max_output_tokens=self.entry.options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
safety_settings=[
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
],
|
||||
tools=tools or None,
|
||||
system_instruction=prompt if supports_system_instruction else None,
|
||||
automatic_function_calling=AutomaticFunctionCallingConfig(
|
||||
disable=True, maximum_remote_calls=None
|
||||
),
|
||||
generateContentConfig = self.create_generate_content_config()
|
||||
generateContentConfig.tools = tools or None
|
||||
generateContentConfig.system_instruction = (
|
||||
prompt if supports_system_instruction else None
|
||||
)
|
||||
generateContentConfig.automatic_function_calling = (
|
||||
AutomaticFunctionCallingConfig(disable=True, maximum_remote_calls=None)
|
||||
)
|
||||
|
||||
if not supports_system_instruction:
|
||||
@@ -472,3 +443,40 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
def create_generate_content_config(self) -> GenerateContentConfig:
|
||||
"""Create the GenerateContentConfig for the LLM."""
|
||||
options = self.subentry.data
|
||||
return GenerateContentConfig(
|
||||
temperature=options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
top_k=options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
||||
top_p=options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
max_output_tokens=options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
safety_settings=[
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold=options.get(
|
||||
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold=options.get(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=options.get(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold=options.get(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Helper classes for Google Generative AI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import io
|
||||
import wave
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .const import LOGGER
|
||||
|
||||
|
||||
def convert_to_wav(audio_data: bytes, mime_type: str) -> bytes:
|
||||
"""Generate a WAV file header for the given audio data and parameters.
|
||||
|
||||
Args:
|
||||
audio_data: The raw audio data as a bytes object.
|
||||
mime_type: Mime type of the audio data.
|
||||
|
||||
Returns:
|
||||
A bytes object representing the WAV file header.
|
||||
|
||||
"""
|
||||
parameters = _parse_audio_mime_type(mime_type)
|
||||
|
||||
wav_buffer = io.BytesIO()
|
||||
with wave.open(wav_buffer, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(parameters["bits_per_sample"] // 8)
|
||||
wf.setframerate(parameters["rate"])
|
||||
wf.writeframes(audio_data)
|
||||
|
||||
return wav_buffer.getvalue()
|
||||
|
||||
|
||||
# Below code is from https://aistudio.google.com/app/generate-speech
|
||||
# when you select "Get SDK code to generate speech".
|
||||
def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
|
||||
"""Parse bits per sample and rate from an audio MIME type string.
|
||||
|
||||
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
|
||||
|
||||
Args:
|
||||
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
|
||||
|
||||
Returns:
|
||||
A dictionary with "bits_per_sample" and "rate" keys. Values will be
|
||||
integers if found, otherwise None.
|
||||
|
||||
"""
|
||||
if not mime_type.startswith("audio/L"):
|
||||
LOGGER.warning("Received unexpected MIME type %s", mime_type)
|
||||
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
|
||||
|
||||
bits_per_sample = 16
|
||||
rate = 24000
|
||||
|
||||
# Extract rate from parameters
|
||||
parts = mime_type.split(";")
|
||||
for param in parts: # Skip the main type part
|
||||
param = param.strip()
|
||||
if param.lower().startswith("rate="):
|
||||
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
|
||||
with suppress(ValueError, IndexError):
|
||||
rate_str = param.split("=", 1)[1]
|
||||
rate = int(rate_str)
|
||||
elif param.startswith("audio/L"):
|
||||
# Keep bits_per_sample as default if conversion fails
|
||||
with suppress(ValueError, IndexError):
|
||||
bits_per_sample = int(param.split("L", 1)[1])
|
||||
|
||||
return {"bits_per_sample": bits_per_sample, "rate": rate}
|
||||
@@ -29,7 +29,6 @@
|
||||
"reconfigure": "Reconfigure conversation agent"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
@@ -61,6 +60,34 @@
|
||||
"error": {
|
||||
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
|
||||
}
|
||||
},
|
||||
"tts": {
|
||||
"initiate_flow": {
|
||||
"user": "Add Text-to-Speech service",
|
||||
"reconfigure": "Reconfigure Text-to-Speech service"
|
||||
},
|
||||
"entry_type": "Text-to-Speech",
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]",
|
||||
"top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]",
|
||||
"top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]",
|
||||
"max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]",
|
||||
"harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]",
|
||||
"hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]",
|
||||
"sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]",
|
||||
"dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import io
|
||||
import logging
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
import wave
|
||||
|
||||
from google.genai import types
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.components.tts import (
|
||||
ATTR_VOICE,
|
||||
@@ -16,15 +15,14 @@ from homeassistant.components.tts import (
|
||||
TtsAudioType,
|
||||
Voice,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import ATTR_MODEL, DOMAIN, RECOMMENDED_TTS_MODEL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import CONF_CHAT_MODEL, LOGGER, RECOMMENDED_TTS_MODEL
|
||||
from .entity import GoogleGenerativeAILLMBaseEntity
|
||||
from .helpers import convert_to_wav
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -32,15 +30,23 @@ async def async_setup_entry(
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up TTS entity."""
|
||||
tts_entity = GoogleGenerativeAITextToSpeechEntity(config_entry)
|
||||
async_add_entities([tts_entity])
|
||||
"""Set up TTS entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "tts":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[GoogleGenerativeAITextToSpeechEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
|
||||
class GoogleGenerativeAITextToSpeechEntity(
|
||||
TextToSpeechEntity, GoogleGenerativeAILLMBaseEntity
|
||||
):
|
||||
"""Google Generative AI text-to-speech entity."""
|
||||
|
||||
_attr_supported_options = [ATTR_VOICE, ATTR_MODEL]
|
||||
_attr_supported_options = [ATTR_VOICE]
|
||||
# See https://ai.google.dev/gemini-api/docs/speech-generation#languages
|
||||
_attr_supported_languages = [
|
||||
"ar-EG",
|
||||
@@ -68,6 +74,8 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
|
||||
"uk-UA",
|
||||
"vi-VN",
|
||||
]
|
||||
# Unused, but required by base class.
|
||||
# The Gemini TTS models detect the input language automatically.
|
||||
_attr_default_language = "en-US"
|
||||
# See https://ai.google.dev/gemini-api/docs/speech-generation#voices
|
||||
_supported_voices = [
|
||||
@@ -106,110 +114,44 @@ class GoogleGenerativeAITextToSpeechEntity(TextToSpeechEntity):
|
||||
)
|
||||
]
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize Google Generative AI Conversation speech entity."""
|
||||
self.entry = entry
|
||||
self._attr_name = "Google Generative AI TTS"
|
||||
self._attr_unique_id = f"{entry.entry_id}_tts"
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Google",
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
self._genai_client = entry.runtime_data
|
||||
self._default_voice_id = self._supported_voices[0].voice_id
|
||||
def __init__(self, config_entry: ConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the TTS entity."""
|
||||
super().__init__(config_entry, subentry, RECOMMENDED_TTS_MODEL)
|
||||
|
||||
@callback
|
||||
def async_get_supported_voices(self, language: str) -> list[Voice] | None:
|
||||
def async_get_supported_voices(self, language: str) -> list[Voice]:
|
||||
"""Return a list of supported voices for a language."""
|
||||
return self._supported_voices
|
||||
|
||||
@cached_property
|
||||
def default_options(self) -> Mapping[str, Any]:
|
||||
"""Return a mapping with the default options."""
|
||||
return {
|
||||
ATTR_VOICE: self._supported_voices[0].voice_id,
|
||||
}
|
||||
|
||||
async def async_get_tts_audio(
|
||||
self, message: str, language: str, options: dict[str, Any]
|
||||
) -> TtsAudioType:
|
||||
"""Load tts audio file from the engine."""
|
||||
try:
|
||||
response = self._genai_client.models.generate_content(
|
||||
model=options.get(ATTR_MODEL, RECOMMENDED_TTS_MODEL),
|
||||
contents=message,
|
||||
config=types.GenerateContentConfig(
|
||||
response_modalities=["AUDIO"],
|
||||
speech_config=types.SpeechConfig(
|
||||
voice_config=types.VoiceConfig(
|
||||
prebuilt_voice_config=types.PrebuiltVoiceConfig(
|
||||
voice_name=options.get(
|
||||
ATTR_VOICE, self._default_voice_id
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
),
|
||||
config = self.create_generate_content_config()
|
||||
config.response_modalities = ["AUDIO"]
|
||||
config.speech_config = types.SpeechConfig(
|
||||
voice_config=types.VoiceConfig(
|
||||
prebuilt_voice_config=types.PrebuiltVoiceConfig(
|
||||
voice_name=options[ATTR_VOICE]
|
||||
)
|
||||
)
|
||||
)
|
||||
try:
|
||||
response = await self._genai_client.aio.models.generate_content(
|
||||
model=self.subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_TTS_MODEL),
|
||||
contents=message,
|
||||
config=config,
|
||||
)
|
||||
|
||||
data = response.candidates[0].content.parts[0].inline_data.data
|
||||
mime_type = response.candidates[0].content.parts[0].inline_data.mime_type
|
||||
except Exception as exc:
|
||||
_LOGGER.warning(
|
||||
"Error during processing of TTS request %s", exc, exc_info=True
|
||||
)
|
||||
except (APIError, ClientError, ValueError) as exc:
|
||||
LOGGER.error("Error during TTS: %s", exc, exc_info=True)
|
||||
raise HomeAssistantError(exc) from exc
|
||||
return "wav", self._convert_to_wav(data, mime_type)
|
||||
|
||||
def _convert_to_wav(self, audio_data: bytes, mime_type: str) -> bytes:
|
||||
"""Generate a WAV file header for the given audio data and parameters.
|
||||
|
||||
Args:
|
||||
audio_data: The raw audio data as a bytes object.
|
||||
mime_type: Mime type of the audio data.
|
||||
|
||||
Returns:
|
||||
A bytes object representing the WAV file header.
|
||||
|
||||
"""
|
||||
parameters = self._parse_audio_mime_type(mime_type)
|
||||
|
||||
wav_buffer = io.BytesIO()
|
||||
with wave.open(wav_buffer, "wb") as wf:
|
||||
wf.setnchannels(1)
|
||||
wf.setsampwidth(parameters["bits_per_sample"] // 8)
|
||||
wf.setframerate(parameters["rate"])
|
||||
wf.writeframes(audio_data)
|
||||
|
||||
return wav_buffer.getvalue()
|
||||
|
||||
def _parse_audio_mime_type(self, mime_type: str) -> dict[str, int]:
|
||||
"""Parse bits per sample and rate from an audio MIME type string.
|
||||
|
||||
Assumes bits per sample is encoded like "L16" and rate as "rate=xxxxx".
|
||||
|
||||
Args:
|
||||
mime_type: The audio MIME type string (e.g., "audio/L16;rate=24000").
|
||||
|
||||
Returns:
|
||||
A dictionary with "bits_per_sample" and "rate" keys. Values will be
|
||||
integers if found, otherwise None.
|
||||
|
||||
"""
|
||||
if not mime_type.startswith("audio/L"):
|
||||
_LOGGER.warning("Received unexpected MIME type %s", mime_type)
|
||||
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
|
||||
|
||||
bits_per_sample = 16
|
||||
rate = 24000
|
||||
|
||||
# Extract rate from parameters
|
||||
parts = mime_type.split(";")
|
||||
for param in parts: # Skip the main type part
|
||||
param = param.strip()
|
||||
if param.lower().startswith("rate="):
|
||||
# Handle cases like "rate=" with no value or non-integer value and keep rate as default
|
||||
with suppress(ValueError, IndexError):
|
||||
rate_str = param.split("=", 1)[1]
|
||||
rate = int(rate_str)
|
||||
elif param.startswith("audio/L"):
|
||||
# Keep bits_per_sample as default if conversion fails
|
||||
with suppress(ValueError, IndexError):
|
||||
bits_per_sample = int(param.split("L", 1)[1])
|
||||
|
||||
return {"bits_per_sample": bits_per_sample, "rate": rate}
|
||||
return "wav", convert_to_wav(data, mime_type)
|
||||
|
||||
@@ -9,7 +9,7 @@ ASSETS_URL = "https://habitica-assets.s3.amazonaws.com/mobileApp/images/"
|
||||
SITE_DATA_URL = "https://habitica.com/user/settings/siteData"
|
||||
FORGOT_PASSWORD_URL = "https://habitica.com/forgot-password"
|
||||
SIGN_UP_URL = "https://habitica.com/register"
|
||||
HABITICANS_URL = "https://habitica.com/static/img/home-main@3x.ffc32b12.png"
|
||||
HABITICANS_URL = "https://cdn.habitica.com/assets/home-main@3x-Dwnue45Z.png"
|
||||
|
||||
DOMAIN = "habitica"
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web
|
||||
from aiohttp.helpers import must_be_empty_body
|
||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
|
||||
from multidict import CIMultiDict
|
||||
from yarl import URL
|
||||
@@ -184,13 +185,16 @@ class HassIOIngress(HomeAssistantView):
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Simple request
|
||||
if result.status in (204, 304) or (
|
||||
if (empty_body := must_be_empty_body(result.method, result.status)) or (
|
||||
content_length is not UNDEFINED
|
||||
and (content_length_int := int(content_length))
|
||||
<= MAX_SIMPLE_RESPONSE_SIZE
|
||||
):
|
||||
# Return Response
|
||||
body = await result.read()
|
||||
if empty_body:
|
||||
body = None
|
||||
else:
|
||||
body = await result.read()
|
||||
simple_response = web.Response(
|
||||
headers=headers,
|
||||
status=result.status,
|
||||
|
||||
@@ -4,6 +4,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohomeconnect.model import GetSetting, Status
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
@@ -11,14 +13,30 @@ from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
|
||||
|
||||
def _serialize_item(item: Status | GetSetting) -> dict[str, Any]:
|
||||
"""Serialize a status or setting item to a dictionary."""
|
||||
data = {"value": item.value}
|
||||
if item.unit is not None:
|
||||
data["unit"] = item.unit
|
||||
if item.constraints is not None:
|
||||
data["constraints"] = {
|
||||
k: v for k, v in item.constraints.to_dict().items() if v is not None
|
||||
}
|
||||
return data
|
||||
|
||||
|
||||
async def _generate_appliance_diagnostics(
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
**appliance.info.to_dict(),
|
||||
"status": {key.value: status.value for key, status in appliance.status.items()},
|
||||
"status": {
|
||||
key.value: _serialize_item(status)
|
||||
for key, status in appliance.status.items()
|
||||
},
|
||||
"settings": {
|
||||
key.value: setting.value for key, setting in appliance.settings.items()
|
||||
key.value: _serialize_item(setting)
|
||||
for key, setting in appliance.settings.items()
|
||||
},
|
||||
"programs": [program.raw_key for program in appliance.programs],
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"macaddress": "68A40E*"
|
||||
},
|
||||
{
|
||||
"hostname": "(siemens|neff)-*",
|
||||
"hostname": "(bosch|neff|siemens)-*",
|
||||
"macaddress": "38B4D3*"
|
||||
}
|
||||
],
|
||||
|
||||
@@ -7,6 +7,11 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import ClientError
|
||||
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
|
||||
from universal_silabs_flasher.common import Version
|
||||
from universal_silabs_flasher.firmware import NabuCasaMetadata
|
||||
|
||||
from homeassistant.components.hassio import (
|
||||
AddonError,
|
||||
AddonInfo,
|
||||
@@ -22,17 +27,18 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from . import silabs_multiprotocol_addon
|
||||
from .const import OTBR_DOMAIN, ZHA_DOMAIN
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
OwningAddon,
|
||||
OwningIntegration,
|
||||
async_flash_silabs_firmware,
|
||||
get_otbr_addon_manager,
|
||||
get_zigbee_flasher_addon_manager,
|
||||
guess_firmware_info,
|
||||
guess_hardware_owners,
|
||||
probe_silabs_firmware_info,
|
||||
@@ -61,6 +67,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self.addon_install_task: asyncio.Task | None = None
|
||||
self.addon_start_task: asyncio.Task | None = None
|
||||
self.addon_uninstall_task: asyncio.Task | None = None
|
||||
self.firmware_install_task: asyncio.Task | None = None
|
||||
self.installing_firmware_name: str | None = None
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
"""Shared translation placeholders."""
|
||||
@@ -77,22 +85,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
return placeholders
|
||||
|
||||
async def _async_set_addon_config(
|
||||
self, config: dict, addon_manager: AddonManager
|
||||
) -> None:
|
||||
"""Set add-on config."""
|
||||
try:
|
||||
await addon_manager.async_set_addon_options(config)
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_set_config_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": addon_manager.addon_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:
|
||||
"""Return add-on info."""
|
||||
try:
|
||||
@@ -150,6 +142,145 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
)
|
||||
)
|
||||
|
||||
async def _install_firmware_step(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
fw_type: str,
|
||||
firmware_name: str,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
step_id: str,
|
||||
next_step_id: str,
|
||||
) -> ConfigFlowResult:
|
||||
assert self._device is not None
|
||||
|
||||
if not self.firmware_install_task:
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
)
|
||||
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
|
||||
try:
|
||||
manifest = await client.async_update_data()
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing):
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch firmware update manifest", exc_info=True
|
||||
)
|
||||
|
||||
# Not having internet access should not prevent setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to index download failure"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
|
||||
# Make sure we do not downgrade the firmware
|
||||
fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata)
|
||||
fw_version = fw_metadata.get_public_version()
|
||||
probed_fw_version = Version(self._probed_firmware_info.firmware_version)
|
||||
|
||||
if probed_fw_version >= fw_version:
|
||||
_LOGGER.debug(
|
||||
"Not downgrading firmware, installed %s is newer than available %s",
|
||||
probed_fw_version,
|
||||
fw_version,
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError):
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
if not firmware_install_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping firmware upgrade due to image download failure"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
# Otherwise, fail
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||
bootloader_reset_type=None,
|
||||
progress_callback=lambda offset, total: self.async_update_progress(
|
||||
offset / total
|
||||
),
|
||||
),
|
||||
f"Flash {firmware_name} firmware",
|
||||
)
|
||||
|
||||
if not self.firmware_install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
progress_action="install_firmware",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
progress_task=self.firmware_install_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.firmware_install_task
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception("Failed to flash firmware")
|
||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
||||
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
async def async_step_firmware_download_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when firmware download failed."""
|
||||
assert self.installing_firmware_name is not None
|
||||
return self.async_abort(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": self.installing_firmware_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_firmware_install_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when firmware install failed."""
|
||||
assert self.installing_firmware_name is not None
|
||||
return self.async_abort(
|
||||
reason="fw_install_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": self.installing_firmware_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -160,68 +291,141 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
# Allow the stick to be used with ZHA without flashing
|
||||
if (
|
||||
self._probed_firmware_info is not None
|
||||
and self._probed_firmware_info.firmware_type == ApplicationType.EZSP
|
||||
):
|
||||
return await self.async_step_confirm_zigbee()
|
||||
return await self.async_step_install_zigbee_firmware()
|
||||
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install Zigbee firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
# Only flash new firmware if we need to
|
||||
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(fw_flasher_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_zigbee_flasher_addon()
|
||||
|
||||
if addon_info.state == AddonState.NOT_RUNNING:
|
||||
return await self.async_step_run_zigbee_flasher_addon()
|
||||
|
||||
# If the addon is already installed and running, fail
|
||||
async def async_step_addon_operation_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when add-on installation or start failed."""
|
||||
return self.async_abort(
|
||||
reason="addon_already_running",
|
||||
reason=self._failed_addon_reason,
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": fw_flasher_manager.addon_name,
|
||||
"addon_name": self._failed_addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_install_zigbee_flasher_addon(
|
||||
async def async_step_pre_confirm_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing the Zigbee flasher addon."""
|
||||
return await self._install_addon(
|
||||
get_zigbee_flasher_addon_manager(self.hass),
|
||||
"install_zigbee_flasher_addon",
|
||||
"run_zigbee_flasher_addon",
|
||||
"""Pre-confirm Zigbee setup."""
|
||||
|
||||
# This step is necessary to prevent `user_input` from being passed through
|
||||
return await self.async_step_confirm_zigbee()
|
||||
|
||||
async def async_step_confirm_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm Zigbee setup."""
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="confirm_zigbee",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
await self.hass.config_entries.flow.async_init(
|
||||
ZHA_DOMAIN,
|
||||
context={"source": "hardware"},
|
||||
data={
|
||||
"name": self._hardware_name,
|
||||
"port": {
|
||||
"path": self._device,
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
},
|
||||
)
|
||||
|
||||
async def _install_addon(
|
||||
self,
|
||||
addon_manager: silabs_multiprotocol_addon.WaitingAddonManager,
|
||||
step_id: str,
|
||||
next_step_id: str,
|
||||
return self._async_flow_finished()
|
||||
|
||||
async def _ensure_thread_addon_setup(self) -> ConfigFlowResult | None:
|
||||
"""Ensure the OTBR addon is set up and not running."""
|
||||
|
||||
# We install the OTBR addon no matter what, since it is required to use Thread
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio_thread",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
if addon_info.state == AddonState.RUNNING:
|
||||
# We only fail setup if we have an instance of OTBR running *and* it's
|
||||
# pointing to different hardware
|
||||
if addon_info.options["device"] != self._device:
|
||||
return self.async_abort(
|
||||
reason="otbr_addon_already_running",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
# Otherwise, stop the addon before continuing to flash firmware
|
||||
await otbr_manager.async_stop_addon()
|
||||
|
||||
return None
|
||||
|
||||
async def async_step_pick_firmware_thread(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing an addon."""
|
||||
"""Pick Thread firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if result := await self._ensure_thread_addon_setup():
|
||||
return result
|
||||
|
||||
return await self.async_step_install_thread_firmware()
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install Thread firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_step_install_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing the OTBR addon."""
|
||||
addon_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(addon_manager)
|
||||
|
||||
_LOGGER.debug("Flasher addon state: %s", addon_info)
|
||||
_LOGGER.debug("OTBR addon info: %s", addon_info)
|
||||
|
||||
if not self.addon_install_task:
|
||||
self.addon_install_task = self.hass.async_create_task(
|
||||
addon_manager.async_install_addon_waiting(),
|
||||
"Addon install",
|
||||
"OTBR addon install",
|
||||
)
|
||||
|
||||
if not self.addon_install_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
step_id="install_otbr_addon",
|
||||
progress_action="install_addon",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
@@ -240,208 +444,50 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
finally:
|
||||
self.addon_install_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
async def async_step_addon_operation_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when add-on installation or start failed."""
|
||||
return self.async_abort(
|
||||
reason=self._failed_addon_reason,
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": self._failed_addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_run_zigbee_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure the flasher addon to point to the SkyConnect and run it."""
|
||||
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(fw_flasher_manager)
|
||||
|
||||
assert self._device is not None
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": self._device,
|
||||
"baudrate": 115200,
|
||||
"bootloader_baudrate": 115200,
|
||||
"flow_control": True,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring flasher addon with %s", new_addon_config)
|
||||
await self._async_set_addon_config(new_addon_config, fw_flasher_manager)
|
||||
|
||||
if not self.addon_start_task:
|
||||
|
||||
async def start_and_wait_until_done() -> None:
|
||||
await fw_flasher_manager.async_start_addon_waiting()
|
||||
# Now that the addon is running, wait for it to finish
|
||||
await fw_flasher_manager.async_wait_until_addon_state(
|
||||
AddonState.NOT_RUNNING
|
||||
)
|
||||
|
||||
self.addon_start_task = self.hass.async_create_task(
|
||||
start_and_wait_until_done()
|
||||
)
|
||||
|
||||
if not self.addon_start_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="run_zigbee_flasher_addon",
|
||||
progress_action="run_zigbee_flasher_addon",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": fw_flasher_manager.addon_name,
|
||||
},
|
||||
progress_task=self.addon_start_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.addon_start_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
self._failed_addon_name = fw_flasher_manager.addon_name
|
||||
self._failed_addon_reason = "addon_start_failed"
|
||||
return self.async_show_progress_done(next_step_id="addon_operation_failed")
|
||||
finally:
|
||||
self.addon_start_task = None
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="uninstall_zigbee_flasher_addon"
|
||||
)
|
||||
|
||||
async def async_step_uninstall_zigbee_flasher_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Uninstall the flasher addon."""
|
||||
fw_flasher_manager = get_zigbee_flasher_addon_manager(self.hass)
|
||||
|
||||
if not self.addon_uninstall_task:
|
||||
_LOGGER.debug("Uninstalling flasher addon")
|
||||
self.addon_uninstall_task = self.hass.async_create_task(
|
||||
fw_flasher_manager.async_uninstall_addon_waiting()
|
||||
)
|
||||
|
||||
if not self.addon_uninstall_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="uninstall_zigbee_flasher_addon",
|
||||
progress_action="uninstall_zigbee_flasher_addon",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": fw_flasher_manager.addon_name,
|
||||
},
|
||||
progress_task=self.addon_uninstall_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.addon_uninstall_task
|
||||
except (AddonError, AbortFlow) as err:
|
||||
_LOGGER.error(err)
|
||||
# The uninstall failing isn't critical so we can just continue
|
||||
finally:
|
||||
self.addon_uninstall_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="confirm_zigbee")
|
||||
|
||||
async def async_step_confirm_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm Zigbee setup."""
|
||||
assert self._device is not None
|
||||
assert self._hardware_name is not None
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
await self.hass.config_entries.flow.async_init(
|
||||
ZHA_DOMAIN,
|
||||
context={"source": "hardware"},
|
||||
data={
|
||||
"name": self._hardware_name,
|
||||
"port": {
|
||||
"path": self._device,
|
||||
"baudrate": 115200,
|
||||
"flow_control": "hardware",
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
},
|
||||
)
|
||||
|
||||
return self._async_flow_finished()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_zigbee",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def async_step_pick_firmware_thread(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick Thread firmware."""
|
||||
if not await self._probe_firmware_info():
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
# We install the OTBR addon no matter what, since it is required to use Thread
|
||||
if not is_hassio(self.hass):
|
||||
return self.async_abort(
|
||||
reason="not_hassio_thread",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
if addon_info.state == AddonState.NOT_INSTALLED:
|
||||
return await self.async_step_install_otbr_addon()
|
||||
|
||||
if addon_info.state == AddonState.NOT_RUNNING:
|
||||
return await self.async_step_start_otbr_addon()
|
||||
|
||||
# If the addon is already installed and running, fail
|
||||
return self.async_abort(
|
||||
reason="otbr_addon_already_running",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_install_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing the OTBR addon."""
|
||||
return await self._install_addon(
|
||||
get_otbr_addon_manager(self.hass), "install_otbr_addon", "start_otbr_addon"
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id="install_thread_firmware")
|
||||
|
||||
async def async_step_start_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||
otbr_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
assert self._device is not None
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": self._device,
|
||||
"baudrate": 460800,
|
||||
"flow_control": True,
|
||||
"autoflash_firmware": True,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
|
||||
await self._async_set_addon_config(new_addon_config, otbr_manager)
|
||||
|
||||
if not self.addon_start_task:
|
||||
# Before we start the addon, confirm that the correct firmware is running
|
||||
# and populate `self._probed_firmware_info` with the correct information
|
||||
if not await self._probe_firmware_info(
|
||||
probe_methods=(ApplicationType.SPINEL,)
|
||||
):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
addon_info = await self._async_get_addon_info(otbr_manager)
|
||||
|
||||
assert self._device is not None
|
||||
new_addon_config = {
|
||||
**addon_info.options,
|
||||
"device": self._device,
|
||||
"baudrate": 460800,
|
||||
"flow_control": True,
|
||||
"autoflash_firmware": False,
|
||||
}
|
||||
|
||||
_LOGGER.debug("Reconfiguring OTBR addon with %s", new_addon_config)
|
||||
|
||||
try:
|
||||
await otbr_manager.async_set_addon_options(new_addon_config)
|
||||
except AddonError as err:
|
||||
_LOGGER.error(err)
|
||||
raise AbortFlow(
|
||||
"addon_set_config_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": otbr_manager.addon_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
self.addon_start_task = self.hass.async_create_task(
|
||||
otbr_manager.async_start_addon_waiting()
|
||||
)
|
||||
@@ -467,7 +513,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
finally:
|
||||
self.addon_start_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="confirm_otbr")
|
||||
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
|
||||
|
||||
async def async_step_pre_confirm_otbr(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pre-confirm OTBR setup."""
|
||||
|
||||
# This step is necessary to prevent `user_input` from being passed through
|
||||
return await self.async_step_confirm_otbr()
|
||||
|
||||
async def async_step_confirm_otbr(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -475,20 +529,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Confirm OTBR setup."""
|
||||
assert self._device is not None
|
||||
|
||||
if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)):
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="confirm_otbr",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
# OTBR discovery is done automatically via hassio
|
||||
return self._async_flow_finished()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="confirm_otbr",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
# OTBR discovery is done automatically via hassio
|
||||
return self._async_flow_finished()
|
||||
|
||||
@abstractmethod
|
||||
def _async_flow_finished(self) -> ConfigFlowResult:
|
||||
|
||||
@@ -10,22 +10,6 @@
|
||||
"pick_firmware_thread": "Thread"
|
||||
}
|
||||
},
|
||||
"install_zigbee_flasher_addon": {
|
||||
"title": "Installing flasher",
|
||||
"description": "Installing the Silicon Labs Flasher add-on."
|
||||
},
|
||||
"run_zigbee_flasher_addon": {
|
||||
"title": "Installing Zigbee firmware",
|
||||
"description": "Installing Zigbee firmware. This will take about a minute."
|
||||
},
|
||||
"uninstall_zigbee_flasher_addon": {
|
||||
"title": "Removing flasher",
|
||||
"description": "Removing the Silicon Labs Flasher add-on."
|
||||
},
|
||||
"zigbee_flasher_failed": {
|
||||
"title": "Zigbee installation failed",
|
||||
"description": "The Zigbee firmware installation process was unsuccessful. Ensure no other software is trying to communicate with the {model} and try again."
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
"title": "Zigbee setup complete",
|
||||
"description": "Your {model} is now a Zigbee coordinator and will be shown as discovered by the Zigbee Home Automation integration."
|
||||
@@ -52,12 +36,12 @@
|
||||
"otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.",
|
||||
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
|
||||
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device."
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
|
||||
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
|
||||
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
|
||||
},
|
||||
"progress": {
|
||||
"install_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is installed, this may take a few minutes.",
|
||||
"run_zigbee_flasher_addon": "Please wait while Zigbee firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes.",
|
||||
"uninstall_zigbee_flasher_addon": "The Silicon Labs Flasher add-on is being removed."
|
||||
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -110,16 +94,6 @@
|
||||
"data": {
|
||||
"disable_multi_pan": "Disable multiprotocol support"
|
||||
}
|
||||
},
|
||||
"install_flasher_addon": {
|
||||
"title": "The Silicon Labs Flasher add-on installation has started"
|
||||
},
|
||||
"configure_flasher_addon": {
|
||||
"title": "The Silicon Labs Flasher add-on installation has started"
|
||||
},
|
||||
"start_flasher_addon": {
|
||||
"title": "Installing firmware",
|
||||
"description": "Zigbee firmware is now being installed. This will take a few minutes."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
|
||||
from universal_silabs_flasher.firmware import parse_firmware_image
|
||||
from universal_silabs_flasher.flasher import Flasher
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.components.update import (
|
||||
@@ -20,18 +17,12 @@ from homeassistant.components.update import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.restore_state import ExtraStoredData
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .coordinator import FirmwareUpdateCoordinator
|
||||
from .helpers import async_register_firmware_info_callback
|
||||
from .util import (
|
||||
ApplicationType,
|
||||
FirmwareInfo,
|
||||
guess_firmware_info,
|
||||
probe_silabs_firmware_info,
|
||||
)
|
||||
from .util import ApplicationType, FirmwareInfo, async_flash_silabs_firmware
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -249,19 +240,11 @@ class BaseFirmwareUpdateEntity(
|
||||
self._attr_update_percentage = round((offset * 100) / total_size)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@asynccontextmanager
|
||||
async def _temporarily_stop_hardware_owners(
|
||||
self, device: str
|
||||
) -> AsyncIterator[None]:
|
||||
"""Temporarily stop addons and integrations communicating with the device."""
|
||||
firmware_info = await guess_firmware_info(self.hass, device)
|
||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
for owner in firmware_info.owners:
|
||||
await stack.enter_async_context(owner.temporarily_stop(self.hass))
|
||||
|
||||
yield
|
||||
# Switch to an indeterminate progress bar after installation is complete, since
|
||||
# we probe the firmware after flashing
|
||||
if offset == total_size:
|
||||
self._attr_update_percentage = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
@@ -278,49 +261,18 @@ class BaseFirmwareUpdateEntity(
|
||||
fw_data = await self.coordinator.client.async_fetch_firmware(
|
||||
self._latest_firmware
|
||||
)
|
||||
fw_image = await self.hass.async_add_executor_job(parse_firmware_image, fw_data)
|
||||
|
||||
device = self._current_device
|
||||
try:
|
||||
firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
device=self._current_device,
|
||||
fw_data=fw_data,
|
||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||
bootloader_reset_type=self.bootloader_reset_type,
|
||||
progress_callback=self._update_progress,
|
||||
)
|
||||
finally:
|
||||
self._attr_in_progress = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
probe_methods=(
|
||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
||||
ApplicationType.EZSP.as_flasher_application_type(),
|
||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||
ApplicationType.CPC.as_flasher_application_type(),
|
||||
),
|
||||
bootloader_reset=self.bootloader_reset_type,
|
||||
)
|
||||
|
||||
async with self._temporarily_stop_hardware_owners(device):
|
||||
try:
|
||||
try:
|
||||
# Enter the bootloader with indeterminate progress
|
||||
await flasher.enter_bootloader()
|
||||
|
||||
# Flash the firmware, with progress
|
||||
await flasher.flash_firmware(
|
||||
fw_image, progress_callback=self._update_progress
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError("Failed to flash firmware") from err
|
||||
|
||||
# Probe the running application type with indeterminate progress
|
||||
self._attr_update_percentage = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
firmware_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
probe_methods=(self.entity_description.expected_firmware_type,),
|
||||
)
|
||||
|
||||
if firmware_info is None:
|
||||
raise HomeAssistantError(
|
||||
"Failed to probe the firmware after flashing"
|
||||
)
|
||||
|
||||
self._firmware_info_callback(firmware_info)
|
||||
finally:
|
||||
self._attr_in_progress = False
|
||||
self.async_write_ha_state()
|
||||
self._firmware_info_callback(firmware_info)
|
||||
|
||||
@@ -4,18 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from contextlib import asynccontextmanager
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
|
||||
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
|
||||
from universal_silabs_flasher.firmware import parse_firmware_image
|
||||
from universal_silabs_flasher.flasher import Flasher
|
||||
|
||||
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.singleton import singleton
|
||||
|
||||
@@ -333,3 +335,52 @@ async def probe_silabs_firmware_type(
|
||||
return None
|
||||
|
||||
return fw_info.firmware_type
|
||||
|
||||
|
||||
async def async_flash_silabs_firmware(
|
||||
hass: HomeAssistant,
|
||||
device: str,
|
||||
fw_data: bytes,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
bootloader_reset_type: str | None = None,
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> FirmwareInfo:
|
||||
"""Flash firmware to the SiLabs device."""
|
||||
firmware_info = await guess_firmware_info(hass, device)
|
||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||
|
||||
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
|
||||
|
||||
flasher = Flasher(
|
||||
device=device,
|
||||
probe_methods=(
|
||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
||||
ApplicationType.EZSP.as_flasher_application_type(),
|
||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
||||
ApplicationType.CPC.as_flasher_application_type(),
|
||||
),
|
||||
bootloader_reset=bootloader_reset_type,
|
||||
)
|
||||
|
||||
async with AsyncExitStack() as stack:
|
||||
for owner in firmware_info.owners:
|
||||
await stack.enter_async_context(owner.temporarily_stop(hass))
|
||||
|
||||
try:
|
||||
# Enter the bootloader with indeterminate progress
|
||||
await flasher.enter_bootloader()
|
||||
|
||||
# Flash the firmware, with progress
|
||||
await flasher.flash_firmware(fw_image, progress_callback=progress_callback)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError("Failed to flash firmware") from err
|
||||
|
||||
probed_firmware_info = await probe_silabs_firmware_info(
|
||||
device,
|
||||
probe_methods=(expected_installed_firmware_type,),
|
||||
)
|
||||
|
||||
if probed_firmware_info is None:
|
||||
raise HomeAssistantError("Failed to probe the firmware after flashing")
|
||||
|
||||
return probed_firmware_info
|
||||
|
||||
@@ -32,6 +32,7 @@ from .const import (
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
MANUFACTURER,
|
||||
NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
PID,
|
||||
PRODUCT,
|
||||
SERIAL_NUMBER,
|
||||
@@ -45,19 +46,29 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class TranslationPlaceholderProtocol(Protocol):
|
||||
"""Protocol describing `BaseFirmwareInstallFlow`'s translation placeholders."""
|
||||
class FirmwareInstallFlowProtocol(Protocol):
|
||||
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
async def _install_firmware_step(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
fw_type: str,
|
||||
firmware_name: str,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
step_id: str,
|
||||
next_step_id: str,
|
||||
) -> ConfigFlowResult: ...
|
||||
|
||||
else:
|
||||
# Multiple inheritance with `Protocol` seems to break
|
||||
TranslationPlaceholderProtocol = object
|
||||
FirmwareInstallFlowProtocol = object
|
||||
|
||||
|
||||
class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProtocol):
|
||||
"""Translation placeholder mixin for Home Assistant SkyConnect."""
|
||||
class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant SkyConnect firmware methods."""
|
||||
|
||||
context: ConfigFlowContext
|
||||
|
||||
@@ -72,9 +83,35 @@ class SkyConnectTranslationMixin(ConfigEntryBaseFlow, TranslationPlaceholderProt
|
||||
|
||||
return placeholders
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install Zigbee firmware."""
|
||||
return await self._install_firmware_step(
|
||||
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
fw_type="skyconnect_zigbee_ncp",
|
||||
firmware_name="Zigbee",
|
||||
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||
step_id="install_zigbee_firmware",
|
||||
next_step_id="pre_confirm_zigbee",
|
||||
)
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install Thread firmware."""
|
||||
return await self._install_firmware_step(
|
||||
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
fw_type="skyconnect_openthread_rcp",
|
||||
firmware_name="OpenThread",
|
||||
expected_installed_firmware_type=ApplicationType.SPINEL,
|
||||
step_id="install_thread_firmware",
|
||||
next_step_id="start_otbr_addon",
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantSkyConnectConfigFlow(
|
||||
SkyConnectTranslationMixin,
|
||||
SkyConnectFirmwareMixin,
|
||||
firmware_config_flow.BaseFirmwareConfigFlow,
|
||||
domain=DOMAIN,
|
||||
):
|
||||
@@ -207,7 +244,7 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler(
|
||||
|
||||
|
||||
class HomeAssistantSkyConnectOptionsFlowHandler(
|
||||
SkyConnectTranslationMixin, firmware_config_flow.BaseFirmwareOptionsFlow
|
||||
SkyConnectFirmwareMixin, firmware_config_flow.BaseFirmwareOptionsFlow
|
||||
):
|
||||
"""Zigbee and Thread options flow handlers."""
|
||||
|
||||
|
||||
@@ -48,16 +48,6 @@
|
||||
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
|
||||
}
|
||||
},
|
||||
"install_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
|
||||
},
|
||||
"configure_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
|
||||
},
|
||||
"start_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
|
||||
},
|
||||
"pick_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
@@ -66,18 +56,6 @@
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
}
|
||||
},
|
||||
"install_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"run_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"zigbee_flasher_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
@@ -114,15 +92,15 @@
|
||||
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
|
||||
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
|
||||
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
|
||||
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
@@ -136,22 +114,6 @@
|
||||
"pick_firmware_thread": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_thread%]"
|
||||
}
|
||||
},
|
||||
"install_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"run_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"uninstall_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::uninstall_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"zigbee_flasher_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
@@ -185,15 +147,15 @@
|
||||
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]"
|
||||
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
|
||||
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
|
||||
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, final
|
||||
from typing import TYPE_CHECKING, Any, Protocol, final
|
||||
|
||||
import aiohttp
|
||||
import voluptuous as vol
|
||||
@@ -31,6 +31,7 @@ from homeassistant.components.homeassistant_hardware.util import (
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_HARDWARE,
|
||||
ConfigEntry,
|
||||
ConfigEntryBaseFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
@@ -41,6 +42,7 @@ from .const import (
|
||||
DOMAIN,
|
||||
FIRMWARE,
|
||||
FIRMWARE_VERSION,
|
||||
NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
RADIO_DEVICE,
|
||||
ZHA_DOMAIN,
|
||||
ZHA_HW_DISCOVERY_DATA,
|
||||
@@ -57,8 +59,59 @@ STEP_HW_SETTINGS_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
class HomeAssistantYellowConfigFlow(BaseFirmwareConfigFlow, domain=DOMAIN):
|
||||
class FirmwareInstallFlowProtocol(Protocol):
|
||||
"""Protocol describing `BaseFirmwareInstallFlow` for a mixin."""
|
||||
|
||||
async def _install_firmware_step(
|
||||
self,
|
||||
fw_update_url: str,
|
||||
fw_type: str,
|
||||
firmware_name: str,
|
||||
expected_installed_firmware_type: ApplicationType,
|
||||
step_id: str,
|
||||
next_step_id: str,
|
||||
) -> ConfigFlowResult: ...
|
||||
|
||||
else:
|
||||
# Multiple inheritance with `Protocol` seems to break
|
||||
FirmwareInstallFlowProtocol = object
|
||||
|
||||
|
||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install Zigbee firmware."""
|
||||
return await self._install_firmware_step(
|
||||
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
fw_type="yellow_zigbee_ncp",
|
||||
firmware_name="Zigbee",
|
||||
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||
step_id="install_zigbee_firmware",
|
||||
next_step_id="confirm_zigbee",
|
||||
)
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Install Thread firmware."""
|
||||
return await self._install_firmware_step(
|
||||
fw_update_url=NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
fw_type="yellow_openthread_rcp",
|
||||
firmware_name="OpenThread",
|
||||
expected_installed_firmware_type=ApplicationType.SPINEL,
|
||||
step_id="install_thread_firmware",
|
||||
next_step_id="start_otbr_addon",
|
||||
)
|
||||
|
||||
|
||||
class HomeAssistantYellowConfigFlow(
|
||||
YellowFirmwareMixin, BaseFirmwareConfigFlow, domain=DOMAIN
|
||||
):
|
||||
"""Handle a config flow for Home Assistant Yellow."""
|
||||
|
||||
VERSION = 1
|
||||
@@ -275,7 +328,9 @@ class HomeAssistantYellowMultiPanOptionsFlowHandler(
|
||||
|
||||
|
||||
class HomeAssistantYellowOptionsFlowHandler(
|
||||
BaseHomeAssistantYellowOptionsFlow, BaseFirmwareOptionsFlow
|
||||
YellowFirmwareMixin,
|
||||
BaseHomeAssistantYellowOptionsFlow,
|
||||
BaseFirmwareOptionsFlow,
|
||||
):
|
||||
"""Handle a firmware options flow for Home Assistant Yellow."""
|
||||
|
||||
|
||||
@@ -71,16 +71,6 @@
|
||||
"disable_multi_pan": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::data::disable_multi_pan%]"
|
||||
}
|
||||
},
|
||||
"install_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_flasher_addon::title%]"
|
||||
},
|
||||
"configure_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::configure_flasher_addon::title%]"
|
||||
},
|
||||
"start_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::start_flasher_addon::description%]"
|
||||
},
|
||||
"pick_firmware": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::description%]",
|
||||
@@ -89,18 +79,6 @@
|
||||
"pick_firmware_zigbee": "[%key:component::homeassistant_hardware::firmware_picker::options::step::pick_firmware::menu_options::pick_firmware_zigbee%]"
|
||||
}
|
||||
},
|
||||
"install_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::install_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"run_zigbee_flasher_addon": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::run_zigbee_flasher_addon::description%]"
|
||||
},
|
||||
"zigbee_flasher_failed": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::zigbee_flasher_failed::description%]"
|
||||
},
|
||||
"confirm_zigbee": {
|
||||
"title": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::title%]",
|
||||
"description": "[%key:component::homeassistant_hardware::firmware_picker::options::step::confirm_zigbee::description%]"
|
||||
@@ -139,15 +117,15 @@
|
||||
"otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]",
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device."
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
"start_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"start_otbr_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::start_addon%]",
|
||||
"install_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_zigbee_flasher_addon%]",
|
||||
"run_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::run_zigbee_flasher_addon%]",
|
||||
"uninstall_zigbee_flasher_addon": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::uninstall_zigbee_flasher_addon%]"
|
||||
"install_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::progress::install_firmware%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
|
||||
@@ -9,17 +9,9 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_create_clientsession,
|
||||
async_get_clientsession,
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
CONF_COOL_AWAY_TEMPERATURE,
|
||||
CONF_HEAT_AWAY_TEMPERATURE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE
|
||||
|
||||
UPDATE_LOOP_SLEEP_TIME = 5
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH]
|
||||
@@ -56,11 +48,11 @@ async def async_setup_entry(
|
||||
username = config_entry.data[CONF_USERNAME]
|
||||
password = config_entry.data[CONF_PASSWORD]
|
||||
|
||||
if len(hass.config_entries.async_entries(DOMAIN)) > 1:
|
||||
session = async_create_clientsession(hass)
|
||||
else:
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
# Always create a new session for Honeywell to prevent cookie injection
|
||||
# issues. Even with response_url handling in aiosomecomfort 0.0.33+,
|
||||
# cookies can still leak into other integrations when using the shared
|
||||
# session. See issue #147395.
|
||||
session = async_create_clientsession(hass)
|
||||
client = aiosomecomfort.AIOSomeComfort(username, password, session=session)
|
||||
try:
|
||||
await client.login()
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_COOL_AWAY_TEMPERATURE,
|
||||
@@ -114,10 +114,14 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def is_valid(self, **kwargs) -> bool:
|
||||
"""Check if login credentials are valid."""
|
||||
# Always create a new session for Honeywell to prevent cookie injection
|
||||
# issues. Even with response_url handling in aiosomecomfort 0.0.33+,
|
||||
# cookies can still leak into other integrations when using the shared
|
||||
# session. See issue #147395.
|
||||
client = aiosomecomfort.AIOSomeComfort(
|
||||
kwargs[CONF_USERNAME],
|
||||
kwargs[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
session=async_create_clientsession(self.hass),
|
||||
)
|
||||
|
||||
await client.login()
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/honeywell",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["somecomfort"],
|
||||
"requirements": ["AIOSomecomfort==0.0.32"]
|
||||
"requirements": ["AIOSomecomfort==0.0.33"]
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ async def async_setup_auth(
|
||||
# We first start with a string check to avoid parsing query params
|
||||
# for every request.
|
||||
elif (
|
||||
request.method == "GET"
|
||||
request.method in ["GET", "HEAD"]
|
||||
and SIGN_QUERY_PARAM in request.query_string
|
||||
and async_validate_signed_request(request)
|
||||
):
|
||||
|
||||
@@ -90,7 +90,9 @@ class AutomowerButtonEntity(AutomowerAvailableEntity, ButtonEntity):
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the available attribute of the entity."""
|
||||
return self.entity_description.available_fn(self.mower_attributes)
|
||||
return super().available and self.entity_description.available_fn(
|
||||
self.mower_attributes
|
||||
)
|
||||
|
||||
@handle_sending_exception()
|
||||
async def async_press(self) -> None:
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
"""The constants for the Husqvarna Automower integration."""
|
||||
|
||||
from aioautomower.model import MowerStates
|
||||
|
||||
DOMAIN = "husqvarna_automower"
|
||||
EXECUTION_TIME_DELAY = 5
|
||||
NAME = "Husqvarna Automower"
|
||||
OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize"
|
||||
OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token"
|
||||
|
||||
ERROR_STATES = [
|
||||
MowerStates.ERROR_AT_POWER_UP,
|
||||
MowerStates.ERROR,
|
||||
MowerStates.FATAL_ERROR,
|
||||
MowerStates.OFF,
|
||||
MowerStates.STOPPED,
|
||||
MowerStates.WAIT_POWER_UP,
|
||||
MowerStates.WAIT_UPDATING,
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .const import DOMAIN, ERROR_STATES
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerAvailableEntity, handle_sending_exception
|
||||
|
||||
@@ -108,18 +108,28 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
||||
def activity(self) -> LawnMowerActivity:
|
||||
"""Return the state of the mower."""
|
||||
mower_attributes = self.mower_attributes
|
||||
if mower_attributes.mower.state in ERROR_STATES:
|
||||
return LawnMowerActivity.ERROR
|
||||
if mower_attributes.mower.state in PAUSED_STATES:
|
||||
return LawnMowerActivity.PAUSED
|
||||
if (mower_attributes.mower.state == "RESTRICTED") or (
|
||||
mower_attributes.mower.activity in DOCKED_ACTIVITIES
|
||||
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
|
||||
return LawnMowerActivity.RETURNING
|
||||
if (
|
||||
mower_attributes.mower.state is MowerStates.RESTRICTED
|
||||
or mower_attributes.mower.activity in DOCKED_ACTIVITIES
|
||||
):
|
||||
return LawnMowerActivity.DOCKED
|
||||
if mower_attributes.mower.state in MowerStates.IN_OPERATION:
|
||||
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
|
||||
return LawnMowerActivity.RETURNING
|
||||
return LawnMowerActivity.MOWING
|
||||
return LawnMowerActivity.ERROR
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the available attribute of the entity."""
|
||||
return (
|
||||
super().available and self.mower_attributes.mower.state != MowerStates.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def work_areas(self) -> dict[int, WorkArea] | None:
|
||||
"""Return the work areas of the mower."""
|
||||
|
||||
@@ -7,13 +7,7 @@ import logging
|
||||
from operator import attrgetter
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aioautomower.model import (
|
||||
MowerAttributes,
|
||||
MowerModes,
|
||||
MowerStates,
|
||||
RestrictedReasons,
|
||||
WorkArea,
|
||||
)
|
||||
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -27,6 +21,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import ERROR_STATES
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import (
|
||||
AutomowerBaseEntity,
|
||||
@@ -166,15 +161,6 @@ ERROR_KEYS = [
|
||||
"zone_generator_problem",
|
||||
]
|
||||
|
||||
ERROR_STATES = [
|
||||
MowerStates.ERROR_AT_POWER_UP,
|
||||
MowerStates.ERROR,
|
||||
MowerStates.FATAL_ERROR,
|
||||
MowerStates.OFF,
|
||||
MowerStates.STOPPED,
|
||||
MowerStates.WAIT_POWER_UP,
|
||||
MowerStates.WAIT_UPDATING,
|
||||
]
|
||||
|
||||
ERROR_KEY_LIST = list(
|
||||
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
|
||||
|
||||
@@ -15,12 +15,14 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from .const import LOGGER
|
||||
from .coordinator import HusqvarnaCoordinator
|
||||
|
||||
type HusqvarnaConfigEntry = ConfigEntry[HusqvarnaCoordinator]
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.LAWN_MOWER,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
|
||||
"""Set up Husqvarna Autoconnect Bluetooth from a config entry."""
|
||||
address = entry.data[CONF_ADDRESS]
|
||||
channel_id = entry.data[CONF_CLIENT_ID]
|
||||
@@ -54,7 +56,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: HusqvarnaConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
coordinator: HusqvarnaCoordinator = entry.runtime_data
|
||||
|
||||
@@ -3,30 +3,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from automower_ble.mower import Mower
|
||||
from bleak import BleakError
|
||||
from bleak_retry_connector import close_stale_connections_by_address
|
||||
|
||||
from homeassistant.components import bluetooth
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import HusqvarnaConfigEntry
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
|
||||
class HusqvarnaCoordinator(DataUpdateCoordinator[dict[str, bytes]]):
|
||||
"""Class to manage fetching data."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HusqvarnaConfigEntry,
|
||||
mower: Mower,
|
||||
address: str,
|
||||
channel_id: str,
|
||||
|
||||
@@ -10,10 +10,10 @@ from homeassistant.components.lawn_mower import (
|
||||
LawnMowerEntity,
|
||||
LawnMowerEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import HusqvarnaConfigEntry
|
||||
from .const import LOGGER
|
||||
from .coordinator import HusqvarnaCoordinator
|
||||
from .entity import HusqvarnaAutomowerBleEntity
|
||||
@@ -21,11 +21,11 @@ from .entity import HusqvarnaAutomowerBleEntity
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: HusqvarnaConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AutomowerLawnMower integration from a config entry."""
|
||||
coordinator: HusqvarnaCoordinator = config_entry.runtime_data
|
||||
coordinator = config_entry.runtime_data
|
||||
address = coordinator.address
|
||||
|
||||
async_add_entities(
|
||||
|
||||
@@ -288,8 +288,10 @@ class ImageView(HomeAssistantView):
|
||||
"""Initialize an image view."""
|
||||
self.component = component
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
async def _authenticate_request(
|
||||
self, request: web.Request, entity_id: str
|
||||
) -> ImageEntity:
|
||||
"""Authenticate request and return image entity."""
|
||||
if (image_entity := self.component.get_entity(entity_id)) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
@@ -306,6 +308,31 @@ class ImageView(HomeAssistantView):
|
||||
# Invalid sigAuth or image entity access token
|
||||
raise web.HTTPForbidden
|
||||
|
||||
return image_entity
|
||||
|
||||
async def head(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
"""Start a HEAD request.
|
||||
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
"""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
|
||||
# Don't use `handle` as we don't care about the stream case, we only want
|
||||
# to verify that the image exists.
|
||||
try:
|
||||
image = await _async_get_image(image_entity, IMAGE_TIMEOUT)
|
||||
except (HomeAssistantError, ValueError) as ex:
|
||||
raise web.HTTPInternalServerError from ex
|
||||
|
||||
return web.Response(
|
||||
content_type=image.content_type,
|
||||
headers={"Content-Length": str(len(image.content))},
|
||||
)
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.StreamResponse:
|
||||
"""Start a GET request."""
|
||||
image_entity = await self._authenticate_request(request, entity_id)
|
||||
return await self.handle(request, image_entity)
|
||||
|
||||
async def handle(
|
||||
@@ -317,7 +344,11 @@ class ImageView(HomeAssistantView):
|
||||
except (HomeAssistantError, ValueError) as ex:
|
||||
raise web.HTTPInternalServerError from ex
|
||||
|
||||
return web.Response(body=image.content, content_type=image.content_type)
|
||||
return web.Response(
|
||||
body=image.content,
|
||||
content_type=image.content_type,
|
||||
headers={"Content-Length": str(len(image.content))},
|
||||
)
|
||||
|
||||
|
||||
async def async_get_still_stream(
|
||||
|
||||
@@ -14,5 +14,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pynecil"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pynecil==4.1.0"]
|
||||
"requirements": ["pynecil==4.1.1"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ista_ecotrend",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyecotrend_ista"],
|
||||
"quality_scale": "gold",
|
||||
"requirements": ["pyecotrend-ista==3.3.1"]
|
||||
}
|
||||
|
||||
@@ -50,14 +50,18 @@ rules:
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: The integration is a web service, there are no discoverable devices.
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-data-update: done
|
||||
docs-examples:
|
||||
status: done
|
||||
comment: describes how to use the integration with the statistics dashboard
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: todo
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: changes are very rare (usually takes years)
|
||||
entity-category:
|
||||
status: done
|
||||
comment: The default category is appropriate.
|
||||
@@ -67,8 +71,12 @@ rules:
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: integration has no repairs
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: integration has no stale devices
|
||||
|
||||
# Platinum
|
||||
async-dependency: todo
|
||||
|
||||
@@ -108,22 +108,22 @@ def get_statistics(
|
||||
if monthly_consumptions := get_consumptions(data, value_type):
|
||||
return [
|
||||
{
|
||||
"value": as_number(
|
||||
get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
).get(
|
||||
"additionalValue"
|
||||
if value_type == IstaValueType.ENERGY
|
||||
else "value"
|
||||
)
|
||||
),
|
||||
"value": as_number(value),
|
||||
"date": consumptions["date"],
|
||||
}
|
||||
for consumptions in monthly_consumptions
|
||||
if get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
).get("additionalValue" if value_type == IstaValueType.ENERGY else "value")
|
||||
if (
|
||||
value := (
|
||||
consumption := get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
)
|
||||
).get(
|
||||
"additionalValue"
|
||||
if value_type == IstaValueType.ENERGY
|
||||
and consumption.get("additionalValue") is not None
|
||||
else "value"
|
||||
)
|
||||
)
|
||||
]
|
||||
return None
|
||||
|
||||
@@ -66,8 +66,7 @@ def _connect_to_address(
|
||||
) -> dict[str, Any]:
|
||||
"""Connect to the Jellyfin server."""
|
||||
result: dict[str, Any] = connection_manager.connect_to_address(url)
|
||||
|
||||
if result["State"] != CONNECTION_STATE["ServerSignIn"]:
|
||||
if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn:
|
||||
raise CannotConnect
|
||||
|
||||
return result
|
||||
|
||||
@@ -54,6 +54,9 @@ class JellyfinDataUpdateCoordinator(DataUpdateCoordinator[dict[str, dict[str, An
|
||||
self.api_client.jellyfin.sessions
|
||||
)
|
||||
|
||||
if sessions is None:
|
||||
return {}
|
||||
|
||||
sessions_by_id: dict[str, dict[str, Any]] = {
|
||||
session["Id"]: session
|
||||
for session in sessions
|
||||
|
||||
@@ -7,6 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["jellyfin_apiclient_python"],
|
||||
"requirements": ["jellyfin-apiclient-python==1.10.0"],
|
||||
"single_config_entry": true
|
||||
"requirements": ["jellyfin-apiclient-python==1.11.0"]
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user