mirror of
https://github.com/home-assistant/core.git
synced 2026-05-23 13:01:46 +00:00
Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9c7397fc32 | |||
| 581fd34264 | |||
| d2e8a48b2c | |||
| ea6332ee42 | |||
| 91c3b43d7f | |||
| 1d82d44794 | |||
| 571376badc | |||
| 32236b2f4d | |||
| 18c1953bc5 | |||
| d874c28dc9 | |||
| 19d89c8952 | |||
| e3ba1f34ca | |||
| b630fb0520 | |||
| 5129f89086 | |||
| 0be0e22e76 | |||
| b8500b338a | |||
| 4cab3a0465 | |||
| ff711324d5 | |||
| 113e7dc003 | |||
| 2120ff6a0a | |||
| 8ee5c30754 | |||
| a1518b96c4 | |||
| bba7f5c3f0 | |||
| 8a5671af76 | |||
| 8a18dea8c7 | |||
| 4b02f22724 | |||
| 7229c2ca2c | |||
| d83eddf13b | |||
| 4a192a7b09 | |||
| 58c434887e | |||
| 78c2405e61 | |||
| 8cc4105984 | |||
| 917f1e4c6f | |||
| 3879f6d2ef | |||
| 78060e4833 | |||
| fda66c4be4 | |||
| 21131d00b3 | |||
| a84313de33 | |||
| c73346e6b3 | |||
| 55a37a2936 | |||
| e481f14335 | |||
| 1ca03c8ae9 | |||
| 61b43ca1fc | |||
| 1b2be083c2 | |||
| 4bdf3d6f30 | |||
| 43535ede8b | |||
| 9bd0762799 | |||
| 1bb653b4f7 | |||
| 2655edcfc8 | |||
| 7a08edc3dd | |||
| b3131355b0 | |||
| 06d04c001d | |||
| babecdf32c | |||
| 17cd39748b | |||
| c2f1e86a4e | |||
| 61a32466b6 | |||
| aef08091f8 | |||
| 1416f0f1e0 | |||
| af7b1a76bc | |||
| bf88fcd5bf | |||
| 35478e3162 | |||
| 69af74a593 | |||
| b4dd912bee | |||
| b5821ef499 | |||
| 1a92d4530e | |||
| 7b80c1c693 | |||
| e7cc03c1d9 | |||
| 69f0b6244a | |||
| 01205f8a14 | |||
| 68924d23ab | |||
| 40f553a007 | |||
| bc46894b74 | |||
| 6f4615f012 | |||
| 4244d2f66f | |||
| a73dafe097 | |||
| be49296547 | |||
| d55ecd885e | |||
| 076248c455 | |||
| 13ce27c94c | |||
| 4b9b08ece5 | |||
| 79df38eff2 | |||
| fb133664e4 | |||
| 38669ce96c | |||
| 651b33d49b | |||
| 3b64db5f76 | |||
| 0f95fe566c | |||
| 6290facffb | |||
| f0a78aadbe | |||
| 345ec97dd5 | |||
| 1286b5d9d8 |
+1148
-87
File diff suppressed because it is too large
Load Diff
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.7"
|
||||
HA_SHORT_VERSION: "2025.8"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
+5
-1
@@ -137,4 +137,8 @@ tmp_cache
|
||||
.ropeproject
|
||||
|
||||
# Will be created from script/split_tests.py
|
||||
pytest_buckets.txt
|
||||
pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.0
|
||||
rev: v0.12.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
Generated
+2
@@ -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
|
||||
|
||||
@@ -607,7 +607,7 @@ async def async_enable_logging(
|
||||
)
|
||||
threading.excepthook = lambda args: logging.getLogger().exception(
|
||||
"Uncaught thread exception",
|
||||
exc_info=( # type: ignore[arg-type] # noqa: LOG014
|
||||
exc_info=( # type: ignore[arg-type]
|
||||
args.exc_type,
|
||||
args.exc_value,
|
||||
args.exc_traceback,
|
||||
@@ -1061,5 +1061,5 @@ async def _async_setup_multi_components(
|
||||
_LOGGER.error(
|
||||
"Error setting up integration %s - received exception",
|
||||
domain,
|
||||
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
|
||||
@@ -336,7 +336,7 @@ 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.1.19"]
|
||||
"requirements": ["aioamazondevices==3.1.22"]
|
||||
}
|
||||
|
||||
@@ -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%]",
|
||||
@@ -71,5 +70,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)
|
||||
|
||||
@@ -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,19 @@ 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,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery_flow
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
@@ -45,6 +45,7 @@ from .manager import (
|
||||
WrittenBackup,
|
||||
)
|
||||
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
||||
from .services import async_setup_services
|
||||
from .util import suggested_filename, suggested_filename_from_name_date
|
||||
from .websocket import async_register_websocket_handlers
|
||||
|
||||
@@ -113,29 +114,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async_register_websocket_handlers(hass, with_hassio)
|
||||
|
||||
async def async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[agent_id],
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_database=True,
|
||||
include_folders=None,
|
||||
include_homeassistant=True,
|
||||
name=None,
|
||||
password=None,
|
||||
)
|
||||
|
||||
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating automatic backups."""
|
||||
await backup_manager.async_create_automatic_backup()
|
||||
|
||||
if not with_hassio:
|
||||
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", async_handle_create_automatic_service
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
async_register_http_views(hass)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""The Backup integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
|
||||
|
||||
async def _async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
backup_manager = call.hass.data[DATA_MANAGER]
|
||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[agent_id],
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_database=True,
|
||||
include_folders=None,
|
||||
include_homeassistant=True,
|
||||
name=None,
|
||||
password=None,
|
||||
)
|
||||
|
||||
|
||||
async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating automatic backups."""
|
||||
await call.hass.data[DATA_MANAGER].async_create_automatic_backup()
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services."""
|
||||
if not is_hassio(hass):
|
||||
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", _async_handle_create_automatic_service
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -9,7 +9,7 @@ from devolo_home_control_api.devices.zwave import Zwave
|
||||
from devolo_home_control_api.homecontrol import HomeControl
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -35,7 +35,7 @@ class DevoloDeviceEntity(Entity):
|
||||
) # This is not doing I/O. It fetches an internal state of the API
|
||||
self._attr_should_poll = False
|
||||
self._attr_unique_id = element_uid
|
||||
self._attr_device_info = DeviceInfo(
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
configuration_url=f"https://{urlparse(device_instance.href).netloc}",
|
||||
identifiers={(DOMAIN, self._device_instance.uid)},
|
||||
manufacturer=device_instance.brand,
|
||||
@@ -88,6 +88,16 @@ class DevoloDeviceEntity(Entity):
|
||||
elif len(message) == 3 and message[2] == "status":
|
||||
# Maybe the API wants to tell us, that the device went on- or offline.
|
||||
self._attr_available = self._device_instance.is_online()
|
||||
elif message[1] == "del" and self.platform.config_entry:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self._device_instance.uid)}
|
||||
)
|
||||
if device:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=self.platform.config_entry.entry_id,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("No valid message received: %s", message)
|
||||
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyW215"],
|
||||
"requirements": ["pyW215==0.7.0"]
|
||||
"requirements": ["pyW215==0.8.0"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250625.0"]
|
||||
"requirements": ["home-assistant-frontend==20250627.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
|
||||
@@ -36,10 +37,13 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_PROMPT,
|
||||
DEFAULT_TITLE,
|
||||
DEFAULT_TTS_NAME,
|
||||
DOMAIN,
|
||||
FILE_POLLING_INTERVAL_SECONDS,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
|
||||
@@ -242,6 +246,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,
|
||||
@@ -270,12 +284,19 @@ 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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
@@ -118,15 +117,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 +177,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 +210,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 +228,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 +252,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 +265,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 +283,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,
|
||||
@@ -310,7 +336,7 @@ async def google_generative_ai_config_option_schema(
|
||||
if (
|
||||
api_model.display_name
|
||||
and api_model.name
|
||||
and "tts" not in 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 +367,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 +427,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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ha_silabs_firmware_client import FirmwareUpdateClient
|
||||
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,
|
||||
@@ -149,15 +152,78 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
assert self._device is not None
|
||||
|
||||
if not self.firmware_install_task:
|
||||
session = async_get_clientsession(self.hass)
|
||||
client = FirmwareUpdateClient(fw_update_url, session)
|
||||
manifest = await client.async_update_data()
|
||||
|
||||
fw_meta = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
# We 100% need to install new firmware only if the wrong firmware is
|
||||
# currently installed
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
)
|
||||
|
||||
fw_data = await client.async_fetch_firmware(fw_meta)
|
||||
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) as err:
|
||||
_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)
|
||||
|
||||
raise AbortFlow(
|
||||
"fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
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) as err:
|
||||
_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
|
||||
raise AbortFlow(
|
||||
"fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
@@ -215,6 +281,14 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pre_confirm_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""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:
|
||||
@@ -409,7 +483,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
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"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."
|
||||
},
|
||||
"progress": {
|
||||
"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."
|
||||
|
||||
@@ -93,7 +93,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
firmware_name="Zigbee",
|
||||
expected_installed_firmware_type=ApplicationType.EZSP,
|
||||
step_id="install_zigbee_firmware",
|
||||
next_step_id="confirm_zigbee",
|
||||
next_step_id="pre_confirm_zigbee",
|
||||
)
|
||||
|
||||
async def async_step_install_thread_firmware(
|
||||
|
||||
@@ -92,7 +92,8 @@
|
||||
"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%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
@@ -145,7 +146,8 @@
|
||||
"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%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
|
||||
@@ -117,7 +117,8 @@
|
||||
"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%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioautomower.model import make_name_string
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerBaseEntity
|
||||
|
||||
@@ -51,6 +54,19 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
|
||||
self._attr_unique_id = mower_id
|
||||
self._event: CalendarEvent | None = None
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
"""Return the prefix for the event summary."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self.mower_id)}
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
assert device_entry is not None
|
||||
assert device_entry.name is not None
|
||||
|
||||
return device_entry.name_by_user or device_entry.name
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the current or next upcoming event."""
|
||||
@@ -66,7 +82,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
|
||||
program_event.work_area_id
|
||||
]
|
||||
return CalendarEvent(
|
||||
summary=make_name_string(work_area_name, program_event.schedule_no),
|
||||
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
|
||||
start=program_event.start,
|
||||
end=program_event.end,
|
||||
rrule=program_event.rrule_str,
|
||||
@@ -93,7 +109,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
|
||||
]
|
||||
calendar_events.append(
|
||||
CalendarEvent(
|
||||
summary=make_name_string(work_area_name, program_event.schedule_no),
|
||||
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
|
||||
start=program_event.start.replace(tzinfo=start_date.tzinfo),
|
||||
end=program_event.end.replace(tzinfo=start_date.tzinfo),
|
||||
rrule=program_event.rrule_str,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2025.6.0"]
|
||||
"requirements": ["aioautomower==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_SERVICE_CODE
|
||||
from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -29,6 +30,7 @@ class PlenticoreSwitchEntityDescription(SwitchEntityDescription):
|
||||
on_label: str
|
||||
off_value: str
|
||||
off_label: str
|
||||
installer_required: bool = False
|
||||
|
||||
|
||||
SWITCH_SETTINGS_DATA = [
|
||||
@@ -42,6 +44,17 @@ SWITCH_SETTINGS_DATA = [
|
||||
off_value="2",
|
||||
off_label="Automatic economical",
|
||||
),
|
||||
PlenticoreSwitchEntityDescription(
|
||||
module_id="devices:local",
|
||||
key="Battery:ManualCharge",
|
||||
name="Battery Manual Charge",
|
||||
is_on="1",
|
||||
on_value="1",
|
||||
on_label="On",
|
||||
off_value="0",
|
||||
off_label="Off",
|
||||
installer_required=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -73,7 +86,13 @@ async def async_setup_entry(
|
||||
description.key,
|
||||
)
|
||||
continue
|
||||
|
||||
if entry.data.get(CONF_SERVICE_CODE) is None and description.installer_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping installer required setting data %s/%s",
|
||||
description.module_id,
|
||||
description.key,
|
||||
)
|
||||
continue
|
||||
entities.append(
|
||||
PlenticoreDataSwitch(
|
||||
settings_data_update_coordinator,
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
from pylamarzocco.const import FirmwareType
|
||||
from pylamarzocco.const import FirmwareType, MachineState, WidgetType
|
||||
from pylamarzocco.models import MachineStatus
|
||||
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_MAC
|
||||
from homeassistant.helpers.device_registry import (
|
||||
@@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity(
|
||||
"""Common elements for all entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_unavailable_when_machine_off = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -63,6 +66,21 @@ class LaMarzoccoBaseEntity(
|
||||
if connections:
|
||||
self._attr_device_info.update(DeviceInfo(connections=connections))
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
machine_state = (
|
||||
cast(
|
||||
MachineStatus,
|
||||
self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS],
|
||||
).status
|
||||
if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config
|
||||
else MachineState.OFF
|
||||
)
|
||||
return super().available and not (
|
||||
self._unavailable_when_machine_off and machine_state is MachineState.OFF
|
||||
)
|
||||
|
||||
|
||||
class LaMarzoccoEntity(LaMarzoccoBaseEntity):
|
||||
"""Common elements for all entities."""
|
||||
|
||||
@@ -58,10 +58,6 @@ ENTITIES: tuple[LaMarzoccoNumberEntityDescription, ...] = (
|
||||
CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER]
|
||||
).target_temperature
|
||||
),
|
||||
available_fn=(
|
||||
lambda coordinator: WidgetType.CM_COFFEE_BOILER
|
||||
in coordinator.device.dashboard.config
|
||||
),
|
||||
),
|
||||
LaMarzoccoNumberEntityDescription(
|
||||
key="smart_standby_time",
|
||||
|
||||
@@ -57,10 +57,6 @@ ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
|
||||
).ready_start_time
|
||||
),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
available_fn=(
|
||||
lambda coordinator: WidgetType.CM_COFFEE_BOILER
|
||||
in coordinator.device.dashboard.config
|
||||
),
|
||||
),
|
||||
LaMarzoccoSensorEntityDescription(
|
||||
key="steam_boiler_ready_time",
|
||||
@@ -188,6 +184,8 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
|
||||
class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity):
|
||||
"""Sensor for La Marzocco statistics."""
|
||||
|
||||
_unavailable_when_machine_off = False
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime | None:
|
||||
"""Return the value of the sensor."""
|
||||
|
||||
@@ -42,5 +42,5 @@ class LaMetricUpdate(LaMetricEntity, UpdateEntity):
|
||||
def latest_version(self) -> str | None:
|
||||
"""Return the latest version of the entity."""
|
||||
if not self.coordinator.data.update:
|
||||
return None
|
||||
return self.coordinator.data.os_version
|
||||
return self.coordinator.data.update.version
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
|
||||
from matter_server.common import custom_clusters
|
||||
|
||||
from homeassistant.components.number import (
|
||||
@@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip
|
||||
"""Describe Matter Number Input entities."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MatterRangeNumberEntityDescription(
|
||||
NumberEntityDescription, MatterEntityDescription
|
||||
):
|
||||
"""Describe Matter Number Input entities with min and max values."""
|
||||
|
||||
ha_to_native_value: Callable[[Any], Any]
|
||||
|
||||
# attribute descriptors to get the min and max value
|
||||
min_attribute: type[ClusterAttributeDescriptor]
|
||||
max_attribute: type[ClusterAttributeDescriptor]
|
||||
|
||||
# command: a custom callback to create the command to send to the device
|
||||
# the callback's argument will be the index of the selected list value
|
||||
command: Callable[[int], ClusterCommand]
|
||||
|
||||
|
||||
class MatterNumber(MatterEntity, NumberEntity):
|
||||
"""Representation of a Matter Attribute as a Number entity."""
|
||||
|
||||
@@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity):
|
||||
self._attr_native_value = value
|
||||
|
||||
|
||||
class MatterRangeNumber(MatterEntity, NumberEntity):
|
||||
"""Representation of a Matter Attribute as a Number entity with min and max values."""
|
||||
|
||||
entity_description: MatterRangeNumberEntityDescription
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
send_value = self.entity_description.ha_to_native_value(value)
|
||||
# custom command defined to set the new value
|
||||
await self.send_device_command(
|
||||
self.entity_description.command(send_value),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
|
||||
if value_convert := self.entity_description.measurement_to_ha:
|
||||
value = value_convert(value)
|
||||
self._attr_native_value = value
|
||||
self._attr_native_min_value = (
|
||||
cast(
|
||||
int,
|
||||
self.get_matter_attribute_value(self.entity_description.min_attribute),
|
||||
)
|
||||
/ 100
|
||||
)
|
||||
self._attr_native_max_value = (
|
||||
cast(
|
||||
int,
|
||||
self.get_matter_attribute_value(self.entity_description.max_attribute),
|
||||
)
|
||||
/ 100
|
||||
)
|
||||
|
||||
|
||||
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||
DISCOVERY_SCHEMAS = [
|
||||
MatterDiscoverySchema(
|
||||
@@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterRangeNumberEntityDescription(
|
||||
key="TemperatureControlTemperatureSetpoint",
|
||||
name=None,
|
||||
translation_key="temperature_setpoint",
|
||||
command=lambda value: clusters.TemperatureControl.Commands.SetTemperature(
|
||||
targetTemperature=value
|
||||
),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
measurement_to_ha=lambda x: None if x is None else x / 100,
|
||||
ha_to_native_value=lambda x: round(x * 100),
|
||||
min_attribute=clusters.TemperatureControl.Attributes.MinTemperature,
|
||||
max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature,
|
||||
mode=NumberMode.SLIDER,
|
||||
),
|
||||
entity_class=MatterRangeNumber,
|
||||
required_attributes=(
|
||||
clusters.TemperatureControl.Attributes.TemperatureSetpoint,
|
||||
clusters.TemperatureControl.Attributes.MinTemperature,
|
||||
clusters.TemperatureControl.Attributes.MaxTemperature,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
"temperature_setpoint": {
|
||||
"name": "Temperature setpoint"
|
||||
},
|
||||
"pir_occupied_to_unoccupied_delay": {
|
||||
"name": "Occupied to unoccupied delay"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import MatterEntity
|
||||
@@ -62,14 +63,36 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
|
||||
_last_accepted_commands: list[int] | None = None
|
||||
_supported_run_modes: (
|
||||
dict[int, clusters.RvcCleanMode.Structs.ModeOptionStruct] | None
|
||||
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
|
||||
) = None
|
||||
entity_description: StateVacuumEntityDescription
|
||||
_platform_translation_key = "vacuum"
|
||||
|
||||
def _get_run_mode_by_tag(
|
||||
self, tag: ModeTag
|
||||
) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None:
|
||||
"""Get the run mode by tag."""
|
||||
supported_run_modes = self._supported_run_modes or {}
|
||||
for mode in supported_run_modes.values():
|
||||
for t in mode.modeTags:
|
||||
if t.value == tag.value:
|
||||
return mode
|
||||
return None
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
"""Stop the vacuum cleaner."""
|
||||
await self.send_device_command(clusters.OperationalState.Commands.Stop())
|
||||
# We simply set the RvcRunMode to the first runmode
|
||||
# that has the idle tag to stop the vacuum cleaner.
|
||||
# this is compatible with both Matter 1.2 and 1.3+ devices.
|
||||
mode = self._get_run_mode_by_tag(ModeTag.IDLE)
|
||||
if mode is None:
|
||||
raise HomeAssistantError(
|
||||
"No supported run mode found to stop the vacuum cleaner."
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Set the vacuum cleaner to return to the dock."""
|
||||
@@ -83,15 +106,31 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
"""Start or resume the cleaning task."""
|
||||
if TYPE_CHECKING:
|
||||
assert self._last_accepted_commands is not None
|
||||
|
||||
accepted_operational_commands = self._last_accepted_commands
|
||||
if (
|
||||
clusters.RvcOperationalState.Commands.Resume.command_id
|
||||
in self._last_accepted_commands
|
||||
in accepted_operational_commands
|
||||
and self.state == VacuumActivity.PAUSED
|
||||
):
|
||||
# vacuum is paused and supports resume command
|
||||
await self.send_device_command(
|
||||
clusters.RvcOperationalState.Commands.Resume()
|
||||
)
|
||||
else:
|
||||
await self.send_device_command(clusters.OperationalState.Commands.Start())
|
||||
return
|
||||
|
||||
# We simply set the RvcRunMode to the first runmode
|
||||
# that has the cleaning tag to start the vacuum cleaner.
|
||||
# this is compatible with both Matter 1.2 and 1.3+ devices.
|
||||
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
|
||||
if mode is None:
|
||||
raise HomeAssistantError(
|
||||
"No supported run mode found to start the vacuum cleaner."
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""Pause the cleaning task."""
|
||||
@@ -130,6 +169,8 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
state = VacuumActivity.CLEANING
|
||||
elif ModeTag.IDLE in tags:
|
||||
state = VacuumActivity.IDLE
|
||||
elif ModeTag.MAPPING in tags:
|
||||
state = VacuumActivity.CLEANING
|
||||
self._attr_activity = state
|
||||
|
||||
@callback
|
||||
@@ -143,7 +184,10 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
return
|
||||
self._last_accepted_commands = accepted_operational_commands
|
||||
supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
|
||||
supported_features |= VacuumEntityFeature.START
|
||||
supported_features |= VacuumEntityFeature.STATE
|
||||
supported_features |= VacuumEntityFeature.STOP
|
||||
|
||||
# optional battery attribute = battery feature
|
||||
if self.get_matter_attribute_value(
|
||||
clusters.PowerSource.Attributes.BatPercentRemaining
|
||||
@@ -153,7 +197,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
if self.get_matter_attribute_value(clusters.Identify.Attributes.IdentifyType):
|
||||
supported_features |= VacuumEntityFeature.LOCATE
|
||||
# create a map of supported run modes
|
||||
run_modes: list[clusters.RvcCleanMode.Structs.ModeOptionStruct] = (
|
||||
run_modes: list[clusters.RvcRunMode.Structs.ModeOptionStruct] = (
|
||||
self.get_matter_attribute_value(
|
||||
clusters.RvcRunMode.Attributes.SupportedModes
|
||||
)
|
||||
@@ -165,22 +209,6 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
in accepted_operational_commands
|
||||
):
|
||||
supported_features |= VacuumEntityFeature.PAUSE
|
||||
if (
|
||||
clusters.OperationalState.Commands.Stop.command_id
|
||||
in accepted_operational_commands
|
||||
):
|
||||
supported_features |= VacuumEntityFeature.STOP
|
||||
if (
|
||||
clusters.OperationalState.Commands.Start.command_id
|
||||
in accepted_operational_commands
|
||||
):
|
||||
# note that start has been replaced by resume in rev2 of the spec
|
||||
supported_features |= VacuumEntityFeature.START
|
||||
if (
|
||||
clusters.RvcOperationalState.Commands.Resume.command_id
|
||||
in accepted_operational_commands
|
||||
):
|
||||
supported_features |= VacuumEntityFeature.START
|
||||
if (
|
||||
clusters.RvcOperationalState.Commands.GoHome.command_id
|
||||
in accepted_operational_commands
|
||||
@@ -202,10 +230,7 @@ DISCOVERY_SCHEMAS = [
|
||||
clusters.RvcRunMode.Attributes.CurrentMode,
|
||||
clusters.RvcOperationalState.Attributes.OperationalState,
|
||||
),
|
||||
optional_attributes=(
|
||||
clusters.RvcCleanMode.Attributes.CurrentMode,
|
||||
clusters.PowerSource.Attributes.BatPercentRemaining,
|
||||
),
|
||||
optional_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
|
||||
device_type=(device_types.RoboticVacuumCleaner,),
|
||||
allow_none_value=True,
|
||||
),
|
||||
|
||||
@@ -25,4 +25,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bo
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: MeaterConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[MEATER_DATA] = (
|
||||
hass.data[MEATER_DATA] - entry.runtime_data.found_probes
|
||||
)
|
||||
return unload_ok
|
||||
|
||||
@@ -44,6 +44,7 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
|
||||
)
|
||||
session = async_get_clientsession(hass)
|
||||
self.client = MeaterApi(session)
|
||||
self.found_probes: set[str] = set()
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the Meater Coordinator."""
|
||||
@@ -73,5 +74,6 @@ class MeaterCoordinator(DataUpdateCoordinator[dict[str, MeaterProbe]]):
|
||||
raise UpdateFailed(
|
||||
"Too many requests have been made to the API, rate limiting is in place"
|
||||
) from err
|
||||
|
||||
return {device.id: device for device in devices}
|
||||
res = {device.id: device for device in devices}
|
||||
self.found_probes.update(set(res.keys()))
|
||||
return res
|
||||
|
||||
@@ -210,10 +210,8 @@ class LocalMediaView(http.HomeAssistantView):
|
||||
self.hass = hass
|
||||
self.source = source
|
||||
|
||||
async def get(
|
||||
self, request: web.Request, source_dir_id: str, location: str
|
||||
) -> web.FileResponse:
|
||||
"""Start a GET request."""
|
||||
async def _validate_media_path(self, source_dir_id: str, location: str) -> Path:
|
||||
"""Validate media path and return it if valid."""
|
||||
try:
|
||||
raise_if_invalid_path(location)
|
||||
except ValueError as err:
|
||||
@@ -233,6 +231,25 @@ class LocalMediaView(http.HomeAssistantView):
|
||||
if not mime_type or mime_type.split("/")[0] not in MEDIA_MIME_TYPES:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
return media_path
|
||||
|
||||
async def head(
|
||||
self, request: web.Request, source_dir_id: str, location: str
|
||||
) -> None:
|
||||
"""Handle a HEAD request.
|
||||
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
|
||||
Check whether the location exists or not.
|
||||
"""
|
||||
await self._validate_media_path(source_dir_id, location)
|
||||
|
||||
async def get(
|
||||
self, request: web.Request, source_dir_id: str, location: str
|
||||
) -> web.FileResponse:
|
||||
"""Handle a GET request."""
|
||||
media_path = await self._validate_media_path(source_dir_id, location)
|
||||
return web.FileResponse(media_path)
|
||||
|
||||
|
||||
|
||||
@@ -41,12 +41,6 @@ class MusicAssistantFavoriteButton(MusicAssistantEntity, ButtonEntity):
|
||||
translation_key="favorite_now_playing",
|
||||
)
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return availability of entity."""
|
||||
# mark the button as unavailable if the player has no current media item
|
||||
return super().available and self.player.current_media is not None
|
||||
|
||||
@catch_musicassistant_error
|
||||
async def async_press(self) -> None:
|
||||
"""Handle the button press command."""
|
||||
|
||||
@@ -39,10 +39,12 @@ def _base_schema(
|
||||
base_schema = {
|
||||
vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str,
|
||||
vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int,
|
||||
vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str,
|
||||
vol.Optional(
|
||||
CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED)
|
||||
): str,
|
||||
vol.Optional(
|
||||
CONF_PASSWORD,
|
||||
default=PASSWORD_NOT_CHANGED if use_password_not_changed else "",
|
||||
default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED,
|
||||
): str,
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from .const import (
|
||||
CONF_NUM_CTX,
|
||||
CONF_PROMPT,
|
||||
CONF_THINK,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
@@ -132,12 +133,19 @@ 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_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
DOMAIN = "ollama"
|
||||
|
||||
DEFAULT_NAME = "Ollama"
|
||||
|
||||
CONF_MODEL = "model"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_THINK = "think"
|
||||
|
||||
@@ -49,6 +49,7 @@ from .const import (
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
@@ -345,12 +346,19 @@ 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_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
)
|
||||
|
||||
@@ -6,12 +6,12 @@ DOMAIN = "openai_conversation"
|
||||
LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
|
||||
DEFAULT_NAME = "OpenAI Conversation"
|
||||
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_FILENAMES = "filenames"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_REASONING_EFFORT = "reasoning_effort"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
|
||||
@@ -247,7 +247,7 @@ class OpenAIConversationEntity(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="OpenAI",
|
||||
model=entry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Select the area in which you want to search for water measuring stations",
|
||||
"data": {
|
||||
"location": "[%key:common::config_flow::data::location%]",
|
||||
"radius": "Search radius"
|
||||
},
|
||||
"data_description": {
|
||||
"location": "Pick the location where to search for water measuring stations.",
|
||||
"radius": "The radius to search for water measuring stations around the selected location."
|
||||
}
|
||||
},
|
||||
"select_station": {
|
||||
"title": "Select the measuring station to add",
|
||||
"title": "Select the station to add",
|
||||
"description": "Found {stations_count} stations in radius",
|
||||
"data": {
|
||||
"station": "Station"
|
||||
},
|
||||
"data_description": {
|
||||
"station": "Select the water measuring station you want to add to Home Assistant."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ from psnawp_api.models.user import User
|
||||
from psnawp_api.utils.misc import parse_npsso_token
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_NAME
|
||||
|
||||
from .const import CONF_NPSSO, DOMAIN, NPSSO_LINK, PSN_LINK
|
||||
@@ -76,13 +76,23 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Perform reauth upon an API authentication error."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Reconfigure flow for PlayStation Network integration."""
|
||||
return await self.async_step_reauth_confirm(user_input)
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauthentication dialog."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reauth_entry()
|
||||
entry = (
|
||||
self._get_reauth_entry()
|
||||
if self.source == SOURCE_REAUTH
|
||||
else self._get_reconfigure_entry()
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
@@ -113,7 +123,7 @@ class PlaystationNetworkConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
step_id="reauth_confirm" if self.source == SOURCE_REAUTH else "reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_USER_DATA_SCHEMA, suggested_values=user_input
|
||||
),
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Diagnostics support for PlayStation Network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from psnawp_api.models.trophies import PlatformType
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import PlaystationNetworkConfigEntry, PlaystationNetworkCoordinator
|
||||
|
||||
TO_REDACT = {
|
||||
"account_id",
|
||||
"firstName",
|
||||
"lastName",
|
||||
"middleName",
|
||||
"onlineId",
|
||||
"url",
|
||||
"username",
|
||||
}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: PlaystationNetworkConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator: PlaystationNetworkCoordinator = entry.runtime_data
|
||||
|
||||
return {
|
||||
"data": async_redact_data(
|
||||
_serialize_platform_types(asdict(coordinator.data)), TO_REDACT
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _serialize_platform_types(data: Any) -> Any:
|
||||
"""Recursively convert PlatformType enums to strings in dicts and sets."""
|
||||
if isinstance(data, dict):
|
||||
return {
|
||||
(
|
||||
platform.value if isinstance(platform, PlatformType) else platform
|
||||
): _serialize_platform_types(record)
|
||||
for platform, record in data.items()
|
||||
}
|
||||
if isinstance(data, set):
|
||||
return [
|
||||
record.value if isinstance(record, PlatformType) else record
|
||||
for record in data
|
||||
]
|
||||
if isinstance(data, PlatformType):
|
||||
return data.value
|
||||
return data
|
||||
@@ -44,7 +44,7 @@ rules:
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
diagnostics: done
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: Discovery flow is not applicable for this integration
|
||||
@@ -63,7 +63,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
# Platinum
|
||||
|
||||
@@ -19,6 +19,16 @@
|
||||
"data_description": {
|
||||
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Update PlayStation Network configuration",
|
||||
"description": "[%key:component::playstation_network::config::step::user::description%]",
|
||||
"data": {
|
||||
"npsso": "[%key:component::playstation_network::config::step::user::data::npsso%]"
|
||||
},
|
||||
"data_description": {
|
||||
"npsso": "[%key:component::playstation_network::config::step::user::data_description::npsso%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
@@ -30,7 +40,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**"
|
||||
"unique_id_mismatch": "The provided NPSSO token corresponds to the account {wrong_account}. Please re-authenticate with the account **{name}**",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -11,7 +11,7 @@ import xmltodict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util.ssl import SSLCipherList
|
||||
|
||||
@@ -94,7 +94,7 @@ class RestData:
|
||||
async def async_update(self, log_errors: bool = True) -> None:
|
||||
"""Get the latest data from REST service with provided method."""
|
||||
if not self._session:
|
||||
self._session = async_get_clientsession(
|
||||
self._session = async_create_clientsession(
|
||||
self._hass,
|
||||
verify_ssl=self._verify_ssl,
|
||||
ssl_cipher=self._ssl_cipher_list,
|
||||
|
||||
@@ -221,8 +221,8 @@ class DimmableRflinkLight(SwitchableRflinkDevice, LightEntity):
|
||||
elif command in ["off", "alloff"]:
|
||||
self._state = False
|
||||
# dimmable device accept 'set_level=(0-15)' commands
|
||||
elif re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE):
|
||||
self._brightness = rflink_to_brightness(int(command.split("=")[1]))
|
||||
elif match := re.search("^set_level=(0?[0-9]|1[0-5])$", command, re.IGNORECASE):
|
||||
self._brightness = rflink_to_brightness(int(match.group(1)))
|
||||
self._state = True
|
||||
|
||||
@property
|
||||
|
||||
@@ -192,8 +192,12 @@ def async_setup_rpc_attribute_entities(
|
||||
if description.removal_condition and description.removal_condition(
|
||||
coordinator.device.config, coordinator.device.status, key
|
||||
):
|
||||
domain = sensor_class.__module__.split(".")[-1]
|
||||
unique_id = f"{coordinator.mac}-{key}-{sensor_id}"
|
||||
entity_class = get_entity_class(sensor_class, description)
|
||||
domain = entity_class.__module__.split(".")[-1]
|
||||
unique_id = entity_class(
|
||||
coordinator, key, sensor_id, description
|
||||
).unique_id
|
||||
LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id)
|
||||
async_remove_shelly_entity(hass, domain, unique_id)
|
||||
elif description.use_polling_coordinator:
|
||||
if not sleep_period:
|
||||
|
||||
@@ -5,7 +5,7 @@ from pysmarlaapi import Connection, Federwiege
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
|
||||
from .const import HOST, PLATFORMS
|
||||
|
||||
@@ -18,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: FederwiegeConfigEntry) -
|
||||
|
||||
# Check if token still has access
|
||||
if not await connection.refresh_token():
|
||||
raise ConfigEntryAuthFailed("Invalid authentication")
|
||||
raise ConfigEntryError("Invalid authentication")
|
||||
|
||||
federwiege = Federwiege(hass.loop, connection)
|
||||
federwiege.register()
|
||||
|
||||
@@ -24,6 +24,20 @@ from .speaker import SonosSpeaker
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SONOS_POWER_SOURCE_BATTERY = "BATTERY"
|
||||
SONOS_POWER_SOURCE_CHARGING_RING = "SONOS_CHARGING_RING"
|
||||
SONOS_POWER_SOURCE_USB = "USB_POWER"
|
||||
|
||||
HA_POWER_SOURCE_BATTERY = "battery"
|
||||
HA_POWER_SOURCE_CHARGING_BASE = "charging_base"
|
||||
HA_POWER_SOURCE_USB = "usb"
|
||||
|
||||
power_source_map = {
|
||||
SONOS_POWER_SOURCE_BATTERY: HA_POWER_SOURCE_BATTERY,
|
||||
SONOS_POWER_SOURCE_CHARGING_RING: HA_POWER_SOURCE_CHARGING_BASE,
|
||||
SONOS_POWER_SOURCE_USB: HA_POWER_SOURCE_USB,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -42,9 +56,15 @@ async def async_setup_entry(
|
||||
|
||||
@callback
|
||||
def _async_create_battery_sensor(speaker: SonosSpeaker) -> None:
|
||||
_LOGGER.debug("Creating battery level sensor on %s", speaker.zone_name)
|
||||
entity = SonosBatteryEntity(speaker, config_entry)
|
||||
async_add_entities([entity])
|
||||
_LOGGER.debug(
|
||||
"Creating battery level and power source sensor on %s", speaker.zone_name
|
||||
)
|
||||
async_add_entities(
|
||||
[
|
||||
SonosBatteryEntity(speaker, config_entry),
|
||||
SonosPowerSourceEntity(speaker, config_entry),
|
||||
]
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_create_favorites_sensor(favorites: SonosFavorites) -> None:
|
||||
@@ -101,6 +121,48 @@ class SonosBatteryEntity(SonosEntity, SensorEntity):
|
||||
return self.speaker.available and self.speaker.power_source is not None
|
||||
|
||||
|
||||
class SonosPowerSourceEntity(SonosEntity, SensorEntity):
|
||||
"""Representation of a Sonos Power Source entity."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.ENUM
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_entity_registry_enabled_default = False
|
||||
_attr_options = [
|
||||
HA_POWER_SOURCE_BATTERY,
|
||||
HA_POWER_SOURCE_CHARGING_BASE,
|
||||
HA_POWER_SOURCE_USB,
|
||||
]
|
||||
_attr_translation_key = "power_source"
|
||||
|
||||
def __init__(self, speaker: SonosSpeaker, config_entry: SonosConfigEntry) -> None:
|
||||
"""Initialize the power source sensor."""
|
||||
super().__init__(speaker, config_entry)
|
||||
self._attr_unique_id = f"{self.soco.uid}-power_source"
|
||||
|
||||
async def _async_fallback_poll(self) -> None:
|
||||
"""Poll the device for the current state."""
|
||||
await self.speaker.async_poll_battery()
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state of the sensor."""
|
||||
if not (power_source := self.speaker.power_source):
|
||||
return None
|
||||
if not (value := power_source_map.get(power_source)):
|
||||
_LOGGER.warning(
|
||||
"Unknown power source '%s' for speaker %s",
|
||||
power_source,
|
||||
self.speaker.zone_name,
|
||||
)
|
||||
return None
|
||||
return value
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether this entity is available."""
|
||||
return self.speaker.available and self.speaker.power_source is not None
|
||||
|
||||
|
||||
class SonosAudioInputFormatSensorEntity(SonosPollingEntity, SensorEntity):
|
||||
"""Representation of a Sonos audio import format sensor entity."""
|
||||
|
||||
|
||||
@@ -53,6 +53,14 @@
|
||||
"sensor": {
|
||||
"audio_input_format": {
|
||||
"name": "Audio input format"
|
||||
},
|
||||
"power_source": {
|
||||
"name": "Power source",
|
||||
"state": {
|
||||
"battery": "Battery",
|
||||
"charging_base": "Charging base",
|
||||
"usb": "USB"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
|
||||
@@ -102,11 +102,11 @@
|
||||
"services": {
|
||||
"unlock_specific_door": {
|
||||
"name": "Unlock specific door",
|
||||
"description": "Unlocks specific door(s).",
|
||||
"description": "Unlocks the driver door, all doors, or the tailgate.",
|
||||
"fields": {
|
||||
"door": {
|
||||
"name": "Door",
|
||||
"description": "Which door(s) to open."
|
||||
"description": "The specific door(s) to unlock."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,12 @@ async def make_device_data(
|
||||
)
|
||||
devices_data.vacuums.append((device, coordinator))
|
||||
|
||||
if isinstance(device, Device) and device.device_type.startswith("Smart Lock"):
|
||||
if isinstance(device, Device) and device.device_type in [
|
||||
"Smart Lock",
|
||||
"Smart Lock Lite",
|
||||
"Smart Lock Pro",
|
||||
"Smart Lock Ultra",
|
||||
]:
|
||||
coordinator = await coordinator_for_device(
|
||||
hass, entry, api, device, coordinators_by_id
|
||||
)
|
||||
|
||||
@@ -48,10 +48,18 @@ BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
"Smart Lock Lite": (
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
"Smart Lock Pro": (
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
"Smart Lock Ultra": (
|
||||
CALIBRATION_DESCRIPTION,
|
||||
DOOR_OPEN_DESCRIPTION,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +77,6 @@ async def async_setup_entry(
|
||||
for description in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[
|
||||
device.device_type
|
||||
]
|
||||
if device.device_type in BINARY_SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -134,8 +134,10 @@ SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES = {
|
||||
BATTERY_DESCRIPTION,
|
||||
CO2_DESCRIPTION,
|
||||
),
|
||||
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock Lite": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock Pro": (BATTERY_DESCRIPTION,),
|
||||
"Smart Lock Ultra": (BATTERY_DESCRIPTION,),
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +153,6 @@ async def async_setup_entry(
|
||||
SwitchBotCloudSensor(data.api, device, coordinator, description)
|
||||
for device, coordinator in data.devices.sensors
|
||||
for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]
|
||||
if device.device_type in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ async def handle_info(
|
||||
"Error fetching system info for %s - %s",
|
||||
domain,
|
||||
key,
|
||||
exc_info=(type(exception), exception, exception.__traceback__), # noqa: LOG014
|
||||
exc_info=(type(exception), exception, exception.__traceback__),
|
||||
)
|
||||
event_msg["success"] = False
|
||||
event_msg["error"] = {"type": "failed", "error": "unknown"}
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.core import (
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
ServiceValidationError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -390,9 +391,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
elif msgtype == SERVICE_DELETE_MESSAGE:
|
||||
await notify_service.delete_message(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_LEAVE_CHAT:
|
||||
messages = await notify_service.leave_chat(
|
||||
context=service.context, **kwargs
|
||||
)
|
||||
await notify_service.leave_chat(context=service.context, **kwargs)
|
||||
elif msgtype == SERVICE_SET_MESSAGE_REACTION:
|
||||
await notify_service.set_message_reaction(context=service.context, **kwargs)
|
||||
else:
|
||||
@@ -400,12 +399,29 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
msgtype, context=service.context, **kwargs
|
||||
)
|
||||
|
||||
if service.return_response and messages:
|
||||
if service.return_response and messages is not None:
|
||||
target: list[int] | None = service.data.get(ATTR_TARGET)
|
||||
if not target:
|
||||
target = notify_service.get_target_chat_ids(None)
|
||||
|
||||
failed_chat_ids = [chat_id for chat_id in target if chat_id not in messages]
|
||||
if failed_chat_ids:
|
||||
raise HomeAssistantError(
|
||||
f"Failed targets: {failed_chat_ids}",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_chat_ids",
|
||||
translation_placeholders={
|
||||
"chat_ids": ", ".join([str(i) for i in failed_chat_ids]),
|
||||
"bot_name": config_entry.title,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"chats": [
|
||||
{"chat_id": cid, "message_id": mid} for cid, mid in messages.items()
|
||||
]
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# Register notification services
|
||||
|
||||
@@ -287,24 +287,32 @@ class TelegramNotificationService:
|
||||
inline_message_id = msg_data["inline_message_id"]
|
||||
return message_id, inline_message_id
|
||||
|
||||
def _get_target_chat_ids(self, target: Any) -> list[int]:
|
||||
def get_target_chat_ids(self, target: int | list[int] | None) -> list[int]:
|
||||
"""Validate chat_id targets or return default target (first).
|
||||
|
||||
:param target: optional list of integers ([12234, -12345])
|
||||
:return list of chat_id targets (integers)
|
||||
"""
|
||||
allowed_chat_ids: list[int] = self._get_allowed_chat_ids()
|
||||
default_user: int = allowed_chat_ids[0]
|
||||
if target is not None:
|
||||
if isinstance(target, int):
|
||||
target = [target]
|
||||
chat_ids = [t for t in target if t in allowed_chat_ids]
|
||||
if chat_ids:
|
||||
return chat_ids
|
||||
_LOGGER.warning(
|
||||
"Disallowed targets: %s, using default: %s", target, default_user
|
||||
|
||||
if target is None:
|
||||
return [allowed_chat_ids[0]]
|
||||
|
||||
chat_ids = [target] if isinstance(target, int) else target
|
||||
valid_chat_ids = [
|
||||
chat_id for chat_id in chat_ids if chat_id in allowed_chat_ids
|
||||
]
|
||||
if not valid_chat_ids:
|
||||
raise ServiceValidationError(
|
||||
"Invalid chat IDs",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_chat_ids",
|
||||
translation_placeholders={
|
||||
"chat_ids": ", ".join(str(chat_id) for chat_id in chat_ids),
|
||||
"bot_name": self.config.title,
|
||||
},
|
||||
)
|
||||
return [default_user]
|
||||
return valid_chat_ids
|
||||
|
||||
def _get_msg_kwargs(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get parameters in message data kwargs."""
|
||||
@@ -414,9 +422,9 @@ class TelegramNotificationService:
|
||||
"""Send one message."""
|
||||
try:
|
||||
out = await func_send(*args_msg, **kwargs_msg)
|
||||
if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID):
|
||||
if isinstance(out, Message):
|
||||
chat_id = out.chat_id
|
||||
message_id = out[ATTR_MESSAGEID]
|
||||
message_id = out.message_id
|
||||
self._last_message_id[chat_id] = message_id
|
||||
_LOGGER.debug(
|
||||
"Last message ID: %s (from chat_id %s)",
|
||||
@@ -424,7 +432,7 @@ class TelegramNotificationService:
|
||||
chat_id,
|
||||
)
|
||||
|
||||
event_data = {
|
||||
event_data: dict[str, Any] = {
|
||||
ATTR_CHAT_ID: chat_id,
|
||||
ATTR_MESSAGEID: message_id,
|
||||
}
|
||||
@@ -437,10 +445,6 @@ class TelegramNotificationService:
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TELEGRAM_SENT, event_data, context=context
|
||||
)
|
||||
elif not isinstance(out, bool):
|
||||
_LOGGER.warning(
|
||||
"Update last message: out_type:%s, out=%s", type(out), out
|
||||
)
|
||||
except TelegramError as exc:
|
||||
_LOGGER.error(
|
||||
"%s: %s. Args: %s, kwargs: %s", msg_error, exc, args_msg, kwargs_msg
|
||||
@@ -460,7 +464,7 @@ class TelegramNotificationService:
|
||||
text = f"{title}\n{message}" if title else message
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
msg_ids = {}
|
||||
for chat_id in self._get_target_chat_ids(target):
|
||||
for chat_id in self.get_target_chat_ids(target):
|
||||
_LOGGER.debug("Send message in chat ID %s with params: %s", chat_id, params)
|
||||
msg = await self._send_msg(
|
||||
self.bot.send_message,
|
||||
@@ -488,7 +492,7 @@ class TelegramNotificationService:
|
||||
**kwargs: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Delete a previously sent message."""
|
||||
chat_id = self._get_target_chat_ids(chat_id)[0]
|
||||
chat_id = self.get_target_chat_ids(chat_id)[0]
|
||||
message_id, _ = self._get_msg_ids(kwargs, chat_id)
|
||||
_LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
|
||||
deleted: bool = await self._send_msg(
|
||||
@@ -513,7 +517,7 @@ class TelegramNotificationService:
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
"""Edit a previously sent message."""
|
||||
chat_id = self._get_target_chat_ids(chat_id)[0]
|
||||
chat_id = self.get_target_chat_ids(chat_id)[0]
|
||||
message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
_LOGGER.debug(
|
||||
@@ -620,7 +624,7 @@ class TelegramNotificationService:
|
||||
|
||||
msg_ids = {}
|
||||
if file_content:
|
||||
for chat_id in self._get_target_chat_ids(target):
|
||||
for chat_id in self.get_target_chat_ids(target):
|
||||
_LOGGER.debug("Sending file to chat ID %s", chat_id)
|
||||
|
||||
if file_type == SERVICE_SEND_PHOTO:
|
||||
@@ -738,7 +742,7 @@ class TelegramNotificationService:
|
||||
|
||||
msg_ids = {}
|
||||
if stickerid:
|
||||
for chat_id in self._get_target_chat_ids(target):
|
||||
for chat_id in self.get_target_chat_ids(target):
|
||||
msg = await self._send_msg(
|
||||
self.bot.send_sticker,
|
||||
"Error sending sticker",
|
||||
@@ -769,7 +773,7 @@ class TelegramNotificationService:
|
||||
longitude = float(longitude)
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
msg_ids = {}
|
||||
for chat_id in self._get_target_chat_ids(target):
|
||||
for chat_id in self.get_target_chat_ids(target):
|
||||
_LOGGER.debug(
|
||||
"Send location %s/%s to chat ID %s", latitude, longitude, chat_id
|
||||
)
|
||||
@@ -803,7 +807,7 @@ class TelegramNotificationService:
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
openperiod = kwargs.get(ATTR_OPEN_PERIOD)
|
||||
msg_ids = {}
|
||||
for chat_id in self._get_target_chat_ids(target):
|
||||
for chat_id in self.get_target_chat_ids(target):
|
||||
_LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id)
|
||||
msg = await self._send_msg(
|
||||
self.bot.send_poll,
|
||||
@@ -826,12 +830,12 @@ class TelegramNotificationService:
|
||||
|
||||
async def leave_chat(
|
||||
self,
|
||||
chat_id: Any = None,
|
||||
chat_id: int | None = None,
|
||||
context: Context | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> Any:
|
||||
"""Remove bot from chat."""
|
||||
chat_id = self._get_target_chat_ids(chat_id)[0]
|
||||
chat_id = self.get_target_chat_ids(chat_id)[0]
|
||||
_LOGGER.debug("Leave from chat ID %s", chat_id)
|
||||
return await self._send_msg(
|
||||
self.bot.leave_chat, "Error leaving chat", None, chat_id, context=context
|
||||
@@ -839,14 +843,14 @@ class TelegramNotificationService:
|
||||
|
||||
async def set_message_reaction(
|
||||
self,
|
||||
chat_id: int,
|
||||
reaction: str,
|
||||
chat_id: int | None = None,
|
||||
is_big: bool = False,
|
||||
context: Context | None = None,
|
||||
**kwargs: dict[str, Any],
|
||||
) -> None:
|
||||
"""Set the bot's reaction for a given message."""
|
||||
chat_id = self._get_target_chat_ids(chat_id)[0]
|
||||
chat_id = self.get_target_chat_ids(chat_id)[0]
|
||||
message_id, _ = self._get_msg_ids(kwargs, chat_id)
|
||||
params = self._get_msg_kwargs(kwargs)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_PLATFORM, CONF_URL
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.data_entry_flow import AbortFlow, section
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
@@ -58,6 +58,7 @@ from .const import (
|
||||
PLATFORM_BROADCAST,
|
||||
PLATFORM_POLLING,
|
||||
PLATFORM_WEBHOOKS,
|
||||
SECTION_ADVANCED_SETTINGS,
|
||||
SUBENTRY_TYPE_ALLOWED_CHAT_IDS,
|
||||
)
|
||||
|
||||
@@ -81,8 +82,15 @@ STEP_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
|
||||
autocomplete="current-password",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_PROXY_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PROXY_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
},
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -98,8 +106,15 @@ STEP_RECONFIGURE_USER_DATA_SCHEMA: vol.Schema = vol.Schema(
|
||||
translation_key="platforms",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_PROXY_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
vol.Required(SECTION_ADVANCED_SETTINGS): section(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_PROXY_URL): TextSelector(
|
||||
config=TextSelectorConfig(type=TextSelectorType.URL)
|
||||
),
|
||||
},
|
||||
),
|
||||
{"collapsed": True},
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -197,6 +212,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
import_data[CONF_TRUSTED_NETWORKS] = ",".join(
|
||||
import_data[CONF_TRUSTED_NETWORKS]
|
||||
)
|
||||
import_data[SECTION_ADVANCED_SETTINGS] = {
|
||||
CONF_PROXY_URL: import_data.get(CONF_PROXY_URL)
|
||||
}
|
||||
try:
|
||||
config_flow_result: ConfigFlowResult = await self.async_step_user(
|
||||
import_data
|
||||
@@ -293,10 +311,15 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow to create a new config entry for a Telegram bot."""
|
||||
|
||||
description_placeholders: dict[str, str] = {
|
||||
"botfather_username": "@BotFather",
|
||||
"botfather_url": "https://t.me/botfather",
|
||||
}
|
||||
if not user_input:
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
description_placeholders=description_placeholders,
|
||||
)
|
||||
|
||||
# prevent duplicates
|
||||
@@ -305,7 +328,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# validate connection to Telegram API
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
bot_name = await self._validate_bot(
|
||||
user_input, errors, description_placeholders
|
||||
)
|
||||
@@ -328,7 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_PLATFORM: user_input[CONF_PLATFORM],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
|
||||
CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL),
|
||||
},
|
||||
options={
|
||||
# this value may come from yaml import
|
||||
@@ -390,12 +412,20 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for webhook Telegram bot."""
|
||||
|
||||
if not user_input:
|
||||
default_trusted_networks = ",".join(
|
||||
[str(network) for network in DEFAULT_TRUSTED_NETWORKS]
|
||||
)
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
suggested_values = dict(self._get_reconfigure_entry().data)
|
||||
if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data:
|
||||
suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="webhooks",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_WEBHOOKS_DATA_SCHEMA,
|
||||
self._get_reconfigure_entry().data,
|
||||
suggested_values,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -404,9 +434,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_WEBHOOKS_DATA_SCHEMA,
|
||||
{
|
||||
CONF_TRUSTED_NETWORKS: ",".join(
|
||||
[str(network) for network in DEFAULT_TRUSTED_NETWORKS]
|
||||
),
|
||||
CONF_TRUSTED_NETWORKS: default_trusted_networks,
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -440,7 +468,9 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_PLATFORM: self._step_user_data[CONF_PLATFORM],
|
||||
CONF_API_KEY: self._step_user_data[CONF_API_KEY],
|
||||
CONF_PROXY_URL: self._step_user_data.get(CONF_PROXY_URL),
|
||||
CONF_PROXY_URL: self._step_user_data[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
),
|
||||
CONF_URL: user_input.get(CONF_URL),
|
||||
CONF_TRUSTED_NETWORKS: user_input[CONF_TRUSTED_NETWORKS],
|
||||
},
|
||||
@@ -455,12 +485,8 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders: dict[str, str],
|
||||
) -> None:
|
||||
# validate URL
|
||||
if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"):
|
||||
errors["base"] = "invalid_url"
|
||||
description_placeholders[ERROR_FIELD] = "URL"
|
||||
description_placeholders[ERROR_MESSAGE] = "URL must start with https"
|
||||
return
|
||||
if CONF_URL not in user_input:
|
||||
url: str | None = user_input.get(CONF_URL)
|
||||
if url is None:
|
||||
try:
|
||||
get_url(self.hass, require_ssl=True, allow_internal=False)
|
||||
except NoURLAvailableError:
|
||||
@@ -470,6 +496,11 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"URL is required since you have not configured an external URL in Home Assistant"
|
||||
)
|
||||
return
|
||||
elif not url.startswith("https"):
|
||||
errors["base"] = "invalid_url"
|
||||
description_placeholders[ERROR_FIELD] = "URL"
|
||||
description_placeholders[ERROR_MESSAGE] = "URL must start with https"
|
||||
return
|
||||
|
||||
# validate trusted networks
|
||||
csv_trusted_networks: list[str] = []
|
||||
@@ -505,9 +536,19 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_RECONFIGURE_USER_DATA_SCHEMA,
|
||||
self._get_reconfigure_entry().data,
|
||||
{
|
||||
**self._get_reconfigure_entry().data,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
CONF_PROXY_URL: self._get_reconfigure_entry().data.get(
|
||||
CONF_PROXY_URL
|
||||
),
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
description_placeholders: dict[str, str] = {}
|
||||
@@ -523,7 +564,12 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_RECONFIGURE_USER_DATA_SCHEMA,
|
||||
user_input,
|
||||
{
|
||||
**user_input,
|
||||
SECTION_ADVANCED_SETTINGS: {
|
||||
CONF_PROXY_URL: user_input.get(CONF_PROXY_URL),
|
||||
},
|
||||
},
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
|
||||
@@ -7,7 +7,7 @@ DOMAIN = "telegram_bot"
|
||||
PLATFORM_BROADCAST = "broadcast"
|
||||
PLATFORM_POLLING = "polling"
|
||||
PLATFORM_WEBHOOKS = "webhooks"
|
||||
|
||||
SECTION_ADVANCED_SETTINGS = "advanced_settings"
|
||||
SUBENTRY_TYPE_ALLOWED_CHAT_IDS = "allowed_chat_ids"
|
||||
|
||||
CONF_BOT_COUNT = "bot_count"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "telegram_bot",
|
||||
"name": "Telegram bot",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@hanwg"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/telegram_bot",
|
||||
|
||||
@@ -2,17 +2,25 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Telegram bot setup",
|
||||
"description": "Create a new Telegram bot",
|
||||
"description": "To create a Telegram bot, follow these steps:\n\n1. Open Telegram and start a chat with [{botfather_username}]({botfather_url}).\n1. Send the command `/newbot`.\n1. Follow the instructions to create your bot and get your API token.",
|
||||
"data": {
|
||||
"platform": "Platform",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"proxy_url": "Proxy URL"
|
||||
"api_key": "[%key:common::config_flow::data::api_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"platform": "Telegram bot implementation",
|
||||
"api_key": "The API token of your bot.",
|
||||
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
|
||||
"api_key": "The API token of your bot."
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"name": "Advanced settings",
|
||||
"data": {
|
||||
"proxy_url": "Proxy URL"
|
||||
},
|
||||
"data_description": {
|
||||
"proxy_url": "Proxy URL if working behind one, optionally including username and password.\n(socks5://username:password@proxy_ip:proxy_port)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhooks": {
|
||||
@@ -30,12 +38,21 @@
|
||||
"title": "Telegram bot setup",
|
||||
"description": "Reconfigure Telegram bot",
|
||||
"data": {
|
||||
"platform": "[%key:component::telegram_bot::config::step::user::data::platform%]",
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::data::proxy_url%]"
|
||||
"platform": "[%key:component::telegram_bot::config::step::user::data::platform%]"
|
||||
},
|
||||
"data_description": {
|
||||
"platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]",
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::data_description::proxy_url%]"
|
||||
"platform": "[%key:component::telegram_bot::config::step::user::data_description::platform%]"
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"name": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::name%]",
|
||||
"data": {
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data::proxy_url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"proxy_url": "[%key:component::telegram_bot::config::step::user::sections::advanced_settings::data_description::proxy_url%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -895,6 +912,12 @@
|
||||
"missing_allowed_chat_ids": {
|
||||
"message": "No allowed chat IDs found. Please add allowed chat IDs for {bot_name}."
|
||||
},
|
||||
"invalid_chat_ids": {
|
||||
"message": "Invalid chat IDs: {chat_ids}. Please configure the chat IDs for {bot_name}."
|
||||
},
|
||||
"failed_chat_ids": {
|
||||
"message": "Failed targets: {chat_ids}. Please verify that the chat IDs for {bot_name} have been configured."
|
||||
},
|
||||
"missing_input": {
|
||||
"message": "{field} is required."
|
||||
},
|
||||
|
||||
@@ -194,14 +194,14 @@ class TeslemetryEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except TeslaFleetError as e:
|
||||
raise UpdateFailed(e.message) from e
|
||||
|
||||
if not data or not isinstance(data.get("time_series"), list):
|
||||
raise UpdateFailed("Received invalid data")
|
||||
|
||||
# Add all time periods together
|
||||
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, None)
|
||||
for period in data.get("time_series", []):
|
||||
output = dict.fromkeys(ENERGY_HISTORY_FIELDS, 0)
|
||||
for period in data["time_series"]:
|
||||
for key in ENERGY_HISTORY_FIELDS:
|
||||
if key in period:
|
||||
if output[key] is None:
|
||||
output[key] = period[key]
|
||||
else:
|
||||
output[key] += period[key]
|
||||
output[key] += period[key]
|
||||
|
||||
return output
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"entity": {
|
||||
"button": {
|
||||
"set_datetime": {
|
||||
"name": "Set Date&Time"
|
||||
"name": "Set date & time"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tibber",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tibber"],
|
||||
"requirements": ["pyTibber==0.31.2"]
|
||||
"requirements": ["pyTibber==0.31.6"]
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ async def async_setup_entry(
|
||||
except TimeoutError as err:
|
||||
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
|
||||
raise PlatformNotReady from err
|
||||
except aiohttp.ClientError as err:
|
||||
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
|
||||
_LOGGER.error("Error connecting to Tibber home: %s ", err)
|
||||
raise PlatformNotReady from err
|
||||
|
||||
|
||||
@@ -1185,6 +1185,21 @@ class TextToSpeechView(HomeAssistantView):
|
||||
"""Initialize a tts view."""
|
||||
self.manager = manager
|
||||
|
||||
async def head(self, request: web.Request, token: str) -> web.StreamResponse:
|
||||
"""Start a HEAD request.
|
||||
|
||||
This is sent by some DLNA renderers, like Samsung ones, prior to sending
|
||||
the GET request.
|
||||
|
||||
Check whether the token (file) exists and return its content type.
|
||||
"""
|
||||
stream = self.manager.token_to_stream.get(token)
|
||||
|
||||
if stream is None:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
return web.Response(content_type=stream.content_type)
|
||||
|
||||
async def get(self, request: web.Request, token: str) -> web.StreamResponse:
|
||||
"""Start a get request."""
|
||||
stream = self.manager.token_to_stream.get(token)
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
from aiohttp.client_exceptions import ServerDisconnectedError
|
||||
from uiprotect.api import DEVICE_UPDATE_INTERVAL
|
||||
from uiprotect.data import Bootstrap
|
||||
from uiprotect.data.types import FirmwareReleaseChannel
|
||||
from uiprotect.exceptions import ClientError, NotAuthorized
|
||||
|
||||
# Import the test_util.anonymize module from the uiprotect package
|
||||
@@ -16,6 +15,7 @@ from uiprotect.exceptions import ClientError, NotAuthorized
|
||||
# diagnostics module will not be imported in the executor.
|
||||
from uiprotect.test_util.anonymize import anonymize_data # noqa: F401
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
@@ -58,10 +58,6 @@ SCAN_INTERVAL = timedelta(seconds=DEVICE_UPDATE_INTERVAL)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
EARLY_ACCESS_URL = (
|
||||
"https://www.home-assistant.io/integrations/unifiprotect#software-support"
|
||||
)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the UniFi Protect."""
|
||||
@@ -123,47 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: UFPConfigEntry) -> bool:
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, data_service.async_stop)
|
||||
)
|
||||
|
||||
if not entry.options.get(CONF_ALLOW_EA, False) and (
|
||||
await nvr_info.get_is_prerelease()
|
||||
or nvr_info.release_channel != FirmwareReleaseChannel.RELEASE
|
||||
):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"ea_channel_warning",
|
||||
is_fixable=True,
|
||||
is_persistent=False,
|
||||
learn_more_url=EARLY_ACCESS_URL,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="ea_channel_warning",
|
||||
translation_placeholders={"version": str(nvr_info.version)},
|
||||
data={"entry_id": entry.entry_id},
|
||||
)
|
||||
|
||||
try:
|
||||
await _async_setup_entry(hass, entry, data_service, bootstrap)
|
||||
except Exception as err:
|
||||
if await nvr_info.get_is_prerelease():
|
||||
# If they are running a pre-release, its quite common for setup
|
||||
# to fail so we want to create a repair issue for them so its
|
||||
# obvious what the problem is.
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"ea_setup_failed_{nvr_info.version}",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/unifiprotect#about-unifi-early-access",
|
||||
severity=IssueSeverity.ERROR,
|
||||
translation_key="ea_setup_failed",
|
||||
translation_placeholders={
|
||||
"error": str(err),
|
||||
"version": str(nvr_info.version),
|
||||
},
|
||||
)
|
||||
ir.async_delete_issue(hass, DOMAIN, "ea_channel_warning")
|
||||
_LOGGER.exception("Error setting up UniFi Protect integration")
|
||||
raise
|
||||
await _async_setup_entry(hass, entry, data_service, bootstrap)
|
||||
|
||||
return True
|
||||
|
||||
@@ -211,3 +167,23 @@ async def async_remove_config_entry_device(
|
||||
if device.is_adopted_by_us and device.mac in unifi_macs:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating configuration from version %s", entry.version)
|
||||
|
||||
if entry.version > 1:
|
||||
return False
|
||||
|
||||
if entry.version == 1:
|
||||
options = dict(entry.options)
|
||||
if CONF_ALLOW_EA in options:
|
||||
options.pop(CONF_ALLOW_EA)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, unique_id=str(entry.unique_id), version=2, options=options
|
||||
)
|
||||
|
||||
_LOGGER.debug("Migration to configuration version %s successful", entry.version)
|
||||
|
||||
return True
|
||||
|
||||
@@ -44,7 +44,6 @@ from homeassistant.util.network import is_ip_address
|
||||
|
||||
from .const import (
|
||||
CONF_ALL_UPDATES,
|
||||
CONF_ALLOW_EA,
|
||||
CONF_DISABLE_RTSP,
|
||||
CONF_MAX_MEDIA,
|
||||
CONF_OVERRIDE_CHOST,
|
||||
@@ -238,7 +237,6 @@ class ProtectFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
CONF_ALL_UPDATES: False,
|
||||
CONF_OVERRIDE_CHOST: False,
|
||||
CONF_MAX_MEDIA: DEFAULT_MAX_MEDIA,
|
||||
CONF_ALLOW_EA: False,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -408,10 +406,6 @@ class OptionsFlowHandler(OptionsFlow):
|
||||
CONF_MAX_MEDIA, DEFAULT_MAX_MEDIA
|
||||
),
|
||||
): vol.All(vol.Coerce(int), vol.Range(min=100, max=10000)),
|
||||
vol.Optional(
|
||||
CONF_ALLOW_EA,
|
||||
default=self.config_entry.options.get(CONF_ALLOW_EA, False),
|
||||
): bool,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -6,7 +6,6 @@ from typing import cast
|
||||
|
||||
from uiprotect import ProtectApiClient
|
||||
from uiprotect.data import Bootstrap, Camera, ModelType
|
||||
from uiprotect.data.types import FirmwareReleaseChannel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant import data_entry_flow
|
||||
@@ -15,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
|
||||
from .const import CONF_ALLOW_EA
|
||||
from .data import UFPConfigEntry, async_get_data_for_entry_id
|
||||
from .utils import async_create_api_client
|
||||
|
||||
@@ -45,52 +43,6 @@ class ProtectRepair(RepairsFlow):
|
||||
return description_placeholders
|
||||
|
||||
|
||||
class EAConfirmRepair(ProtectRepair):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the first step of a fix flow."""
|
||||
|
||||
return await self.async_step_start()
|
||||
|
||||
async def async_step_start(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is None:
|
||||
placeholders = self._async_get_placeholders()
|
||||
return self.async_show_form(
|
||||
step_id="start",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
nvr = await self._api.get_nvr()
|
||||
if nvr.release_channel != FirmwareReleaseChannel.RELEASE:
|
||||
return await self.async_step_confirm()
|
||||
await self.hass.config_entries.async_reload(self._entry.entry_id)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, str] | None = None
|
||||
) -> data_entry_flow.FlowResult:
|
||||
"""Handle the confirm step of a fix flow."""
|
||||
if user_input is not None:
|
||||
options = dict(self._entry.options)
|
||||
options[CONF_ALLOW_EA] = True
|
||||
self.hass.config_entries.async_update_entry(self._entry, options=options)
|
||||
return self.async_create_entry(data={})
|
||||
|
||||
placeholders = self._async_get_placeholders()
|
||||
return self.async_show_form(
|
||||
step_id="confirm",
|
||||
data_schema=vol.Schema({}),
|
||||
description_placeholders=placeholders,
|
||||
)
|
||||
|
||||
|
||||
class CloudAccountRepair(ProtectRepair):
|
||||
"""Handler for an issue fixing flow."""
|
||||
|
||||
@@ -242,8 +194,6 @@ async def async_create_fix_flow(
|
||||
and (entry := hass.config_entries.async_get_entry(cast(str, data["entry_id"])))
|
||||
):
|
||||
api = _async_get_or_create_api_client(hass, entry)
|
||||
if issue_id == "ea_channel_warning":
|
||||
return EAConfirmRepair(api=api, entry=entry)
|
||||
if issue_id == "cloud_user":
|
||||
return CloudAccountRepair(api=api, entry=entry)
|
||||
if issue_id.startswith("rtsp_disabled_"):
|
||||
|
||||
@@ -55,32 +55,12 @@
|
||||
"disable_rtsp": "Disable the RTSP stream",
|
||||
"all_updates": "Realtime metrics (WARNING: Greatly increases CPU usage)",
|
||||
"override_connection_host": "Override connection host",
|
||||
"max_media": "Max number of event to load for Media Browser (increases RAM usage)",
|
||||
"allow_ea_channel": "Allow Early Access versions of Protect (WARNING: Will mark your integration as unsupported)"
|
||||
"max_media": "Max number of event to load for Media Browser (increases RAM usage)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"ea_channel_warning": {
|
||||
"title": "UniFi Protect Early Access enabled",
|
||||
"fix_flow": {
|
||||
"step": {
|
||||
"start": {
|
||||
"title": "UniFi Protect Early Access enabled",
|
||||
"description": "You are either running an Early Access version of UniFi Protect (v{version}) or opt-ed into a release channel that is not the official release channel.\n\nAs these Early Access releases may not be tested yet, using it may cause the UniFi Protect integration to behave unexpectedly. [Read more about Early Access and Home Assistant]({learn_more}).\n\nSubmit to dismiss this message."
|
||||
},
|
||||
"confirm": {
|
||||
"title": "[%key:component::unifiprotect::issues::ea_channel_warning::fix_flow::step::start::title%]",
|
||||
"description": "Are you sure you want to run unsupported versions of UniFi Protect? This may cause your Home Assistant integration to break."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ea_setup_failed": {
|
||||
"title": "Setup error using Early Access version",
|
||||
"description": "You are using v{version} of UniFi Protect which is an Early Access version. An unrecoverable error occurred while trying to load the integration. Please restore a backup of a stable release of UniFi Protect to continue using the integration.\n\nError: {error}"
|
||||
},
|
||||
"cloud_user": {
|
||||
"title": "Ubiquiti Cloud Users are not Supported",
|
||||
"fix_flow": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user