mirror of
https://github.com/home-assistant/core.git
synced 2025-07-17 18:27:09 +00:00
Merge branch 'dev' into scop-huawei-lte-diags-support
This commit is contained in:
commit
7a1230e24e
@ -535,6 +535,7 @@ homeassistant.components.unifiprotect.*
|
|||||||
homeassistant.components.upcloud.*
|
homeassistant.components.upcloud.*
|
||||||
homeassistant.components.update.*
|
homeassistant.components.update.*
|
||||||
homeassistant.components.uptime.*
|
homeassistant.components.uptime.*
|
||||||
|
homeassistant.components.uptime_kuma.*
|
||||||
homeassistant.components.uptimerobot.*
|
homeassistant.components.uptimerobot.*
|
||||||
homeassistant.components.usb.*
|
homeassistant.components.usb.*
|
||||||
homeassistant.components.uvc.*
|
homeassistant.components.uvc.*
|
||||||
|
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@ -1658,6 +1658,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/upnp/ @StevenLooman
|
/tests/components/upnp/ @StevenLooman
|
||||||
/homeassistant/components/uptime/ @frenck
|
/homeassistant/components/uptime/ @frenck
|
||||||
/tests/components/uptime/ @frenck
|
/tests/components/uptime/ @frenck
|
||||||
|
/homeassistant/components/uptime_kuma/ @tr4nt0r
|
||||||
|
/tests/components/uptime_kuma/ @tr4nt0r
|
||||||
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
/homeassistant/components/uptimerobot/ @ludeeus @chemelli74
|
||||||
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
/tests/components/uptimerobot/ @ludeeus @chemelli74
|
||||||
/homeassistant/components/usb/ @bdraco
|
/homeassistant/components/usb/ @bdraco
|
||||||
@ -1756,8 +1758,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/wirelesstag/ @sergeymaysak
|
/homeassistant/components/wirelesstag/ @sergeymaysak
|
||||||
/homeassistant/components/withings/ @joostlek
|
/homeassistant/components/withings/ @joostlek
|
||||||
/tests/components/withings/ @joostlek
|
/tests/components/withings/ @joostlek
|
||||||
/homeassistant/components/wiz/ @sbidy
|
/homeassistant/components/wiz/ @sbidy @arturpragacz
|
||||||
/tests/components/wiz/ @sbidy
|
/tests/components/wiz/ @sbidy @arturpragacz
|
||||||
/homeassistant/components/wled/ @frenck
|
/homeassistant/components/wled/ @frenck
|
||||||
/tests/components/wled/ @frenck
|
/tests/components/wled/ @frenck
|
||||||
/homeassistant/components/wmspro/ @mback2k
|
/homeassistant/components/wmspro/ @mback2k
|
||||||
|
@ -33,7 +33,7 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import AITaskEntity
|
from .entity import AITaskEntity
|
||||||
from .http import async_setup as async_setup_http
|
from .http import async_setup as async_setup_http
|
||||||
from .task import GenDataTask, GenDataTaskResult, PlayMediaWithId, async_generate_data
|
from .task import GenDataTask, GenDataTaskResult, async_generate_data
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
@ -41,7 +41,6 @@ __all__ = [
|
|||||||
"AITaskEntityFeature",
|
"AITaskEntityFeature",
|
||||||
"GenDataTask",
|
"GenDataTask",
|
||||||
"GenDataTaskResult",
|
"GenDataTaskResult",
|
||||||
"PlayMediaWithId",
|
|
||||||
"async_generate_data",
|
"async_generate_data",
|
||||||
"async_setup",
|
"async_setup",
|
||||||
"async_setup_entry",
|
"async_setup_entry",
|
||||||
|
@ -13,7 +13,7 @@ from homeassistant.components.conversation import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||||
from homeassistant.helpers import llm
|
from homeassistant.helpers import llm
|
||||||
from homeassistant.helpers.chat_session import async_get_chat_session
|
from homeassistant.helpers.chat_session import ChatSession
|
||||||
from homeassistant.helpers.restore_state import RestoreEntity
|
from homeassistant.helpers.restore_state import RestoreEntity
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
@ -56,12 +56,12 @@ class AITaskEntity(RestoreEntity):
|
|||||||
@contextlib.asynccontextmanager
|
@contextlib.asynccontextmanager
|
||||||
async def _async_get_ai_task_chat_log(
|
async def _async_get_ai_task_chat_log(
|
||||||
self,
|
self,
|
||||||
|
session: ChatSession,
|
||||||
task: GenDataTask,
|
task: GenDataTask,
|
||||||
) -> AsyncGenerator[ChatLog]:
|
) -> AsyncGenerator[ChatLog]:
|
||||||
"""Context manager used to manage the ChatLog used during an AI Task."""
|
"""Context manager used to manage the ChatLog used during an AI Task."""
|
||||||
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
# pylint: disable-next=contextmanager-generator-missing-cleanup
|
||||||
with (
|
with (
|
||||||
async_get_chat_session(self.hass) as session,
|
|
||||||
async_get_chat_log(
|
async_get_chat_log(
|
||||||
self.hass,
|
self.hass,
|
||||||
session,
|
session,
|
||||||
@ -79,19 +79,22 @@ class AITaskEntity(RestoreEntity):
|
|||||||
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
user_llm_prompt=DEFAULT_SYSTEM_PROMPT,
|
||||||
)
|
)
|
||||||
|
|
||||||
chat_log.async_add_user_content(UserContent(task.instructions))
|
chat_log.async_add_user_content(
|
||||||
|
UserContent(task.instructions, attachments=task.attachments)
|
||||||
|
)
|
||||||
|
|
||||||
yield chat_log
|
yield chat_log
|
||||||
|
|
||||||
@final
|
@final
|
||||||
async def internal_async_generate_data(
|
async def internal_async_generate_data(
|
||||||
self,
|
self,
|
||||||
|
session: ChatSession,
|
||||||
task: GenDataTask,
|
task: GenDataTask,
|
||||||
) -> GenDataTaskResult:
|
) -> GenDataTaskResult:
|
||||||
"""Run a gen data task."""
|
"""Run a gen data task."""
|
||||||
self.__last_activity = dt_util.utcnow().isoformat()
|
self.__last_activity = dt_util.utcnow().isoformat()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
async with self._async_get_ai_task_chat_log(task) as chat_log:
|
async with self._async_get_ai_task_chat_log(session, task) as chat_log:
|
||||||
return await self._async_generate_data(task, chat_log)
|
return await self._async_generate_data(task, chat_log)
|
||||||
|
|
||||||
async def _async_generate_data(
|
async def _async_generate_data(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"domain": "ai_task",
|
"domain": "ai_task",
|
||||||
"name": "AI Task",
|
"name": "AI Task",
|
||||||
|
"after_dependencies": ["camera"],
|
||||||
"codeowners": ["@home-assistant/core"],
|
"codeowners": ["@home-assistant/core"],
|
||||||
"dependencies": ["conversation", "media_source"],
|
"dependencies": ["conversation", "media_source"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
"documentation": "https://www.home-assistant.io/integrations/ai_task",
|
||||||
|
@ -10,6 +10,7 @@ generate_data:
|
|||||||
required: true
|
required: true
|
||||||
selector:
|
selector:
|
||||||
text:
|
text:
|
||||||
|
multiline: true
|
||||||
entity_id:
|
entity_id:
|
||||||
required: false
|
required: false
|
||||||
selector:
|
selector:
|
||||||
|
@ -2,28 +2,31 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, fields
|
from dataclasses import dataclass
|
||||||
|
import mimetypes
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components import media_source
|
from homeassistant.components import camera, conversation, media_source
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.chat_session import async_get_chat_session
|
||||||
|
|
||||||
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
def _save_camera_snapshot(image: camera.Image) -> Path:
|
||||||
class PlayMediaWithId(media_source.PlayMedia):
|
"""Save camera snapshot to temp file."""
|
||||||
"""Play media with a media content ID."""
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="wb",
|
||||||
media_content_id: str
|
suffix=mimetypes.guess_extension(image.content_type, False),
|
||||||
"""Media source ID to play."""
|
delete=False,
|
||||||
|
) as temp_file:
|
||||||
def __str__(self) -> str:
|
temp_file.write(image.content)
|
||||||
"""Return media source ID as a string."""
|
return Path(temp_file.name)
|
||||||
return f"<PlayMediaWithId {self.media_content_id}>"
|
|
||||||
|
|
||||||
|
|
||||||
async def async_generate_data(
|
async def async_generate_data(
|
||||||
@ -52,38 +55,79 @@ async def async_generate_data(
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Resolve attachments
|
# Resolve attachments
|
||||||
resolved_attachments: list[PlayMediaWithId] | None = None
|
resolved_attachments: list[conversation.Attachment] = []
|
||||||
|
created_files: list[Path] = []
|
||||||
|
|
||||||
if attachments:
|
if (
|
||||||
if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features:
|
attachments
|
||||||
raise HomeAssistantError(
|
and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features
|
||||||
f"AI Task entity {entity_id} does not support attachments"
|
):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"AI Task entity {entity_id} does not support attachments"
|
||||||
|
)
|
||||||
|
|
||||||
|
for attachment in attachments or []:
|
||||||
|
media_content_id = attachment["media_content_id"]
|
||||||
|
|
||||||
|
# Special case for camera media sources
|
||||||
|
if media_content_id.startswith("media-source://camera/"):
|
||||||
|
# Extract entity_id from the media content ID
|
||||||
|
entity_id = media_content_id.removeprefix("media-source://camera/")
|
||||||
|
|
||||||
|
# Get snapshot from camera
|
||||||
|
image = await camera.async_get_image(hass, entity_id)
|
||||||
|
|
||||||
|
temp_filename = await hass.async_add_executor_job(
|
||||||
|
_save_camera_snapshot, image
|
||||||
)
|
)
|
||||||
|
created_files.append(temp_filename)
|
||||||
|
|
||||||
resolved_attachments = []
|
|
||||||
|
|
||||||
for attachment in attachments:
|
|
||||||
media = await media_source.async_resolve_media(
|
|
||||||
hass, attachment["media_content_id"], None
|
|
||||||
)
|
|
||||||
resolved_attachments.append(
|
resolved_attachments.append(
|
||||||
PlayMediaWithId(
|
conversation.Attachment(
|
||||||
**{
|
media_content_id=media_content_id,
|
||||||
field.name: getattr(media, field.name)
|
mime_type=image.content_type,
|
||||||
for field in fields(media)
|
path=temp_filename,
|
||||||
},
|
)
|
||||||
media_content_id=attachment["media_content_id"],
|
)
|
||||||
|
else:
|
||||||
|
# Handle regular media sources
|
||||||
|
media = await media_source.async_resolve_media(hass, media_content_id, None)
|
||||||
|
if media.path is None:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Only local attachments are currently supported"
|
||||||
|
)
|
||||||
|
resolved_attachments.append(
|
||||||
|
conversation.Attachment(
|
||||||
|
media_content_id=media_content_id,
|
||||||
|
mime_type=media.mime_type,
|
||||||
|
path=media.path,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return await entity.internal_async_generate_data(
|
with async_get_chat_session(hass) as session:
|
||||||
GenDataTask(
|
if created_files:
|
||||||
name=task_name,
|
|
||||||
instructions=instructions,
|
def cleanup_files() -> None:
|
||||||
structure=structure,
|
"""Cleanup temporary files."""
|
||||||
attachments=resolved_attachments,
|
for file in created_files:
|
||||||
|
file.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def cleanup_files_callback() -> None:
|
||||||
|
"""Cleanup temporary files."""
|
||||||
|
hass.async_add_executor_job(cleanup_files)
|
||||||
|
|
||||||
|
session.async_on_cleanup(cleanup_files_callback)
|
||||||
|
|
||||||
|
return await entity.internal_async_generate_data(
|
||||||
|
session,
|
||||||
|
GenDataTask(
|
||||||
|
name=task_name,
|
||||||
|
instructions=instructions,
|
||||||
|
structure=structure,
|
||||||
|
attachments=resolved_attachments or None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@ -99,7 +143,7 @@ class GenDataTask:
|
|||||||
structure: vol.Schema | None = None
|
structure: vol.Schema | None = None
|
||||||
"""Optional structure for the data to be generated."""
|
"""Optional structure for the data to be generated."""
|
||||||
|
|
||||||
attachments: list[PlayMediaWithId] | None = None
|
attachments: list[conversation.Attachment] | None = None
|
||||||
"""List of attachments to go along the instructions."""
|
"""List of attachments to go along the instructions."""
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
@ -6,6 +6,5 @@ CONF_RETURN_AVERAGE: Final = "return_average"
|
|||||||
CONF_CLIP_NEGATIVE: Final = "clip_negatives"
|
CONF_CLIP_NEGATIVE: Final = "clip_negatives"
|
||||||
DOMAIN: Final = "airq"
|
DOMAIN: Final = "airq"
|
||||||
MANUFACTURER: Final = "CorantGmbH"
|
MANUFACTURER: Final = "CorantGmbH"
|
||||||
CONCENTRATION_GRAMS_PER_CUBIC_METER: Final = "g/m³"
|
|
||||||
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
|
ACTIVITY_BECQUEREL_PER_CUBIC_METER: Final = "Bq/m³"
|
||||||
UPDATE_INTERVAL: float = 10.0
|
UPDATE_INTERVAL: float = 10.0
|
||||||
|
@ -4,9 +4,6 @@
|
|||||||
"health_index": {
|
"health_index": {
|
||||||
"default": "mdi:heart-pulse"
|
"default": "mdi:heart-pulse"
|
||||||
},
|
},
|
||||||
"absolute_humidity": {
|
|
||||||
"default": "mdi:water"
|
|
||||||
},
|
|
||||||
"oxygen": {
|
"oxygen": {
|
||||||
"default": "mdi:leaf"
|
"default": "mdi:leaf"
|
||||||
},
|
},
|
||||||
|
@ -14,6 +14,7 @@ from homeassistant.components.sensor import (
|
|||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||||
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER,
|
||||||
CONCENTRATION_PARTS_PER_BILLION,
|
CONCENTRATION_PARTS_PER_BILLION,
|
||||||
@ -28,10 +29,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import AirQConfigEntry, AirQCoordinator
|
from . import AirQConfigEntry, AirQCoordinator
|
||||||
from .const import (
|
from .const import ACTIVITY_BECQUEREL_PER_CUBIC_METER
|
||||||
ACTIVITY_BECQUEREL_PER_CUBIC_METER,
|
|
||||||
CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -195,7 +193,7 @@ SENSOR_TYPES: list[AirQEntityDescription] = [
|
|||||||
),
|
),
|
||||||
AirQEntityDescription(
|
AirQEntityDescription(
|
||||||
key="humidity_abs",
|
key="humidity_abs",
|
||||||
translation_key="absolute_humidity",
|
device_class=SensorDeviceClass.ABSOLUTE_HUMIDITY,
|
||||||
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
native_unit_of_measurement=CONCENTRATION_GRAMS_PER_CUBIC_METER,
|
||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
value=lambda data: data.get("humidity_abs"),
|
value=lambda data: data.get("humidity_abs"),
|
||||||
|
@ -93,9 +93,6 @@
|
|||||||
"health_index": {
|
"health_index": {
|
||||||
"name": "Health index"
|
"name": "Health index"
|
||||||
},
|
},
|
||||||
"absolute_humidity": {
|
|
||||||
"name": "Absolute humidity"
|
|
||||||
},
|
|
||||||
"hydrogen": {
|
"hydrogen": {
|
||||||
"name": "Hydrogen"
|
"name": "Hydrogen"
|
||||||
},
|
},
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
"documentation": "https://www.home-assistant.io/integrations/airzone_cloud",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aioairzone_cloud"],
|
"loggers": ["aioairzone_cloud"],
|
||||||
"requirements": ["aioairzone-cloud==0.6.12"]
|
"requirements": ["aioairzone-cloud==0.6.13"]
|
||||||
}
|
}
|
||||||
|
@ -505,8 +505,13 @@ class ClimateCapabilities(AlexaEntity):
|
|||||||
):
|
):
|
||||||
yield AlexaThermostatController(self.hass, self.entity)
|
yield AlexaThermostatController(self.hass, self.entity)
|
||||||
yield AlexaTemperatureSensor(self.hass, self.entity)
|
yield AlexaTemperatureSensor(self.hass, self.entity)
|
||||||
if self.entity.domain == water_heater.DOMAIN and (
|
if (
|
||||||
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
self.entity.domain == water_heater.DOMAIN
|
||||||
|
and (
|
||||||
|
supported_features
|
||||||
|
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
||||||
|
)
|
||||||
|
and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST)
|
||||||
):
|
):
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity,
|
self.entity,
|
||||||
@ -634,7 +639,9 @@ class FanCapabilities(AlexaEntity):
|
|||||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
|
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
|
||||||
)
|
)
|
||||||
force_range_controller = False
|
force_range_controller = False
|
||||||
if supported & fan.FanEntityFeature.PRESET_MODE:
|
if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
|
||||||
|
fan.ATTR_PRESET_MODES
|
||||||
|
):
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
|
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
|
||||||
)
|
)
|
||||||
@ -672,7 +679,11 @@ class RemoteCapabilities(AlexaEntity):
|
|||||||
yield AlexaPowerController(self.entity)
|
yield AlexaPowerController(self.entity)
|
||||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
||||||
if activities and supported & remote.RemoteEntityFeature.ACTIVITY:
|
if (
|
||||||
|
activities
|
||||||
|
and (supported & remote.RemoteEntityFeature.ACTIVITY)
|
||||||
|
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
|
||||||
|
):
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
||||||
)
|
)
|
||||||
@ -692,7 +703,9 @@ class HumidifierCapabilities(AlexaEntity):
|
|||||||
"""Yield the supported interfaces."""
|
"""Yield the supported interfaces."""
|
||||||
yield AlexaPowerController(self.entity)
|
yield AlexaPowerController(self.entity)
|
||||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||||
if supported & humidifier.HumidifierEntityFeature.MODES:
|
if (
|
||||||
|
supported & humidifier.HumidifierEntityFeature.MODES
|
||||||
|
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
|
||||||
yield AlexaModeController(
|
yield AlexaModeController(
|
||||||
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
|
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
|
||||||
)
|
)
|
||||||
|
@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["aioamazondevices"],
|
"loggers": ["aioamazondevices"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["aioamazondevices==3.2.8"]
|
"requirements": ["aioamazondevices==3.2.10"]
|
||||||
}
|
}
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["amcrest"],
|
"loggers": ["amcrest"],
|
||||||
"quality_scale": "legacy",
|
"quality_scale": "legacy",
|
||||||
"requirements": ["amcrest==1.9.8"]
|
"requirements": ["amcrest==1.9.9"]
|
||||||
}
|
}
|
||||||
|
@ -10,9 +10,9 @@ DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
|||||||
CONF_RECOMMENDED = "recommended"
|
CONF_RECOMMENDED = "recommended"
|
||||||
CONF_PROMPT = "prompt"
|
CONF_PROMPT = "prompt"
|
||||||
CONF_CHAT_MODEL = "chat_model"
|
CONF_CHAT_MODEL = "chat_model"
|
||||||
RECOMMENDED_CHAT_MODEL = "claude-3-haiku-20240307"
|
RECOMMENDED_CHAT_MODEL = "claude-3-5-haiku-latest"
|
||||||
CONF_MAX_TOKENS = "max_tokens"
|
CONF_MAX_TOKENS = "max_tokens"
|
||||||
RECOMMENDED_MAX_TOKENS = 1024
|
RECOMMENDED_MAX_TOKENS = 3000
|
||||||
CONF_TEMPERATURE = "temperature"
|
CONF_TEMPERATURE = "temperature"
|
||||||
RECOMMENDED_TEMPERATURE = 1.0
|
RECOMMENDED_TEMPERATURE = 1.0
|
||||||
CONF_THINKING_BUDGET = "thinking_budget"
|
CONF_THINKING_BUDGET = "thinking_budget"
|
||||||
|
@ -7,7 +7,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
"documentation": "https://www.home-assistant.io/integrations/apple_tv",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["pyatv", "srptools"],
|
"loggers": ["pyatv", "srptools"],
|
||||||
"requirements": ["pyatv==0.16.0"],
|
"requirements": ["pyatv==0.16.1"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
"_mediaremotetv._tcp.local.",
|
"_mediaremotetv._tcp.local.",
|
||||||
"_companion-link._tcp.local.",
|
"_companion-link._tcp.local.",
|
||||||
|
@ -15,12 +15,12 @@
|
|||||||
],
|
],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"bleak==0.22.3",
|
"bleak==1.0.1",
|
||||||
"bleak-retry-connector==3.9.0",
|
"bleak-retry-connector==4.0.0",
|
||||||
"bluetooth-adapters==0.21.4",
|
"bluetooth-adapters==2.0.0",
|
||||||
"bluetooth-auto-recovery==1.5.2",
|
"bluetooth-auto-recovery==1.5.2",
|
||||||
"bluetooth-data-tools==1.28.2",
|
"bluetooth-data-tools==1.28.2",
|
||||||
"dbus-fast==2.43.0",
|
"dbus-fast==2.43.0",
|
||||||
"habluetooth==3.49.0"
|
"habluetooth==4.0.1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ DOMAINS_AND_TYPES = {
|
|||||||
Platform.SELECT: {"HYS"},
|
Platform.SELECT: {"HYS"},
|
||||||
Platform.SENSOR: {
|
Platform.SENSOR: {
|
||||||
"A1",
|
"A1",
|
||||||
|
"A2",
|
||||||
"MP1S",
|
"MP1S",
|
||||||
"RM4MINI",
|
"RM4MINI",
|
||||||
"RM4PRO",
|
"RM4PRO",
|
||||||
|
@ -10,6 +10,7 @@ from homeassistant.components.sensor import (
|
|||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
UnitOfElectricCurrent,
|
UnitOfElectricCurrent,
|
||||||
UnitOfElectricPotential,
|
UnitOfElectricPotential,
|
||||||
@ -34,6 +35,24 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
|||||||
key="air_quality",
|
key="air_quality",
|
||||||
device_class=SensorDeviceClass.AQI,
|
device_class=SensorDeviceClass.AQI,
|
||||||
),
|
),
|
||||||
|
SensorEntityDescription(
|
||||||
|
key="pm10",
|
||||||
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
|
device_class=SensorDeviceClass.PM10,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
SensorEntityDescription(
|
||||||
|
key="pm2_5",
|
||||||
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
|
device_class=SensorDeviceClass.PM25,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
|
SensorEntityDescription(
|
||||||
|
key="pm1",
|
||||||
|
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
|
||||||
|
device_class=SensorDeviceClass.PM1,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
SensorEntityDescription(
|
SensorEntityDescription(
|
||||||
key="humidity",
|
key="humidity",
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
@ -25,6 +25,7 @@ def get_update_manager(device: BroadlinkDevice[_ApiT]) -> BroadlinkUpdateManager
|
|||||||
"""Return an update manager for a given Broadlink device."""
|
"""Return an update manager for a given Broadlink device."""
|
||||||
update_managers: dict[str, type[BroadlinkUpdateManager]] = {
|
update_managers: dict[str, type[BroadlinkUpdateManager]] = {
|
||||||
"A1": BroadlinkA1UpdateManager,
|
"A1": BroadlinkA1UpdateManager,
|
||||||
|
"A2": BroadlinkA2UpdateManager,
|
||||||
"BG1": BroadlinkBG1UpdateManager,
|
"BG1": BroadlinkBG1UpdateManager,
|
||||||
"HYS": BroadlinkThermostatUpdateManager,
|
"HYS": BroadlinkThermostatUpdateManager,
|
||||||
"LB1": BroadlinkLB1UpdateManager,
|
"LB1": BroadlinkLB1UpdateManager,
|
||||||
@ -118,6 +119,16 @@ class BroadlinkA1UpdateManager(BroadlinkUpdateManager[blk.a1]):
|
|||||||
return await self.device.async_request(self.device.api.check_sensors_raw)
|
return await self.device.async_request(self.device.api.check_sensors_raw)
|
||||||
|
|
||||||
|
|
||||||
|
class BroadlinkA2UpdateManager(BroadlinkUpdateManager[blk.a2]):
|
||||||
|
"""Manages updates for Broadlink A2 devices."""
|
||||||
|
|
||||||
|
SCAN_INTERVAL = timedelta(seconds=10)
|
||||||
|
|
||||||
|
async def async_fetch_data(self) -> dict[str, Any]:
|
||||||
|
"""Fetch data from the device."""
|
||||||
|
return await self.device.async_request(self.device.api.check_sensors_raw)
|
||||||
|
|
||||||
|
|
||||||
class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]):
|
class BroadlinkMP1UpdateManager(BroadlinkUpdateManager[blk.mp1]):
|
||||||
"""Manages updates for Broadlink MP1 devices."""
|
"""Manages updates for Broadlink MP1 devices."""
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
||||||
"requirements": ["brother==4.3.1"],
|
"requirements": ["brother==5.0.0"],
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
"type": "_printer._tcp.local.",
|
"type": "_printer._tcp.local.",
|
||||||
|
@ -12,6 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNA
|
|||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
from homeassistant.helpers.device_registry import format_mac
|
from homeassistant.helpers.device_registry import format_mac
|
||||||
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN
|
from .const import CONF_PASSKEY, DEFAULT_PORT, DOMAIN
|
||||||
|
|
||||||
@ -21,12 +22,15 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
VERSION = 1
|
VERSION = 1
|
||||||
|
|
||||||
host: str
|
def __init__(self) -> None:
|
||||||
port: int
|
"""Initialize BSBLan flow."""
|
||||||
mac: str
|
self.host: str | None = None
|
||||||
passkey: str | None = None
|
self.port: int = DEFAULT_PORT
|
||||||
username: str | None = None
|
self.mac: str | None = None
|
||||||
password: str | None = None
|
self.passkey: str | None = None
|
||||||
|
self.username: str | None = None
|
||||||
|
self.password: str | None = None
|
||||||
|
self._auth_required = True
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@ -41,9 +45,111 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
self.username = user_input.get(CONF_USERNAME)
|
self.username = user_input.get(CONF_USERNAME)
|
||||||
self.password = user_input.get(CONF_PASSWORD)
|
self.password = user_input.get(CONF_PASSWORD)
|
||||||
|
|
||||||
|
return await self._validate_and_create()
|
||||||
|
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle Zeroconf discovery."""
|
||||||
|
|
||||||
|
self.host = str(discovery_info.ip_address)
|
||||||
|
self.port = discovery_info.port or DEFAULT_PORT
|
||||||
|
|
||||||
|
# Get MAC from properties
|
||||||
|
self.mac = discovery_info.properties.get("mac")
|
||||||
|
|
||||||
|
# If MAC was found in zeroconf, use it immediately
|
||||||
|
if self.mac:
|
||||||
|
await self.async_set_unique_id(format_mac(self.mac))
|
||||||
|
self._abort_if_unique_id_configured(
|
||||||
|
updates={
|
||||||
|
CONF_HOST: self.host,
|
||||||
|
CONF_PORT: self.port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# MAC not available from zeroconf - check for existing host/port first
|
||||||
|
self._async_abort_entries_match(
|
||||||
|
{CONF_HOST: self.host, CONF_PORT: self.port}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to get device info without authentication to minimize discovery popup
|
||||||
|
config = BSBLANConfig(host=self.host, port=self.port)
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
bsblan = BSBLAN(config, session)
|
||||||
|
try:
|
||||||
|
device = await bsblan.device()
|
||||||
|
except BSBLANError:
|
||||||
|
# Device requires authentication - proceed to discovery confirm
|
||||||
|
self.mac = None
|
||||||
|
else:
|
||||||
|
self.mac = device.MAC
|
||||||
|
|
||||||
|
# Got MAC without auth - set unique ID and check for existing device
|
||||||
|
await self.async_set_unique_id(format_mac(self.mac))
|
||||||
|
self._abort_if_unique_id_configured(
|
||||||
|
updates={
|
||||||
|
CONF_HOST: self.host,
|
||||||
|
CONF_PORT: self.port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# No auth needed, so we can proceed to a confirmation step without fields
|
||||||
|
self._auth_required = False
|
||||||
|
|
||||||
|
# Proceed to get credentials
|
||||||
|
self.context["title_placeholders"] = {"name": f"BSBLAN {self.host}"}
|
||||||
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
|
async def async_step_discovery_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle getting credentials for discovered device."""
|
||||||
|
if user_input is None:
|
||||||
|
data_schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_PASSKEY): str,
|
||||||
|
vol.Optional(CONF_USERNAME): str,
|
||||||
|
vol.Optional(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not self._auth_required:
|
||||||
|
data_schema = vol.Schema({})
|
||||||
|
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
data_schema=data_schema,
|
||||||
|
description_placeholders={"host": str(self.host)},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._auth_required:
|
||||||
|
return self._async_create_entry()
|
||||||
|
|
||||||
|
self.passkey = user_input.get(CONF_PASSKEY)
|
||||||
|
self.username = user_input.get(CONF_USERNAME)
|
||||||
|
self.password = user_input.get(CONF_PASSWORD)
|
||||||
|
|
||||||
|
return await self._validate_and_create(is_discovery=True)
|
||||||
|
|
||||||
|
async def _validate_and_create(
|
||||||
|
self, is_discovery: bool = False
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Validate device connection and create entry."""
|
||||||
try:
|
try:
|
||||||
await self._get_bsblan_info()
|
await self._get_bsblan_info(is_discovery=is_discovery)
|
||||||
except BSBLANError:
|
except BSBLANError:
|
||||||
|
if is_discovery:
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="discovery_confirm",
|
||||||
|
data_schema=vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Optional(CONF_PASSKEY): str,
|
||||||
|
vol.Optional(CONF_USERNAME): str,
|
||||||
|
vol.Optional(CONF_PASSWORD): str,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
errors={"base": "cannot_connect"},
|
||||||
|
description_placeholders={"host": str(self.host)},
|
||||||
|
)
|
||||||
return self._show_setup_form({"base": "cannot_connect"})
|
return self._show_setup_form({"base": "cannot_connect"})
|
||||||
|
|
||||||
return self._async_create_entry()
|
return self._async_create_entry()
|
||||||
@ -67,6 +173,7 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_create_entry(self) -> ConfigFlowResult:
|
def _async_create_entry(self) -> ConfigFlowResult:
|
||||||
|
"""Create the config entry."""
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=format_mac(self.mac),
|
title=format_mac(self.mac),
|
||||||
data={
|
data={
|
||||||
@ -78,8 +185,10 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _get_bsblan_info(self, raise_on_progress: bool = True) -> None:
|
async def _get_bsblan_info(
|
||||||
"""Get device information from an BSBLAN device."""
|
self, raise_on_progress: bool = True, is_discovery: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Get device information from a BSBLAN device."""
|
||||||
config = BSBLANConfig(
|
config = BSBLANConfig(
|
||||||
host=self.host,
|
host=self.host,
|
||||||
passkey=self.passkey,
|
passkey=self.passkey,
|
||||||
@ -90,11 +199,18 @@ class BSBLANFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
session = async_get_clientsession(self.hass)
|
session = async_get_clientsession(self.hass)
|
||||||
bsblan = BSBLAN(config, session)
|
bsblan = BSBLAN(config, session)
|
||||||
device = await bsblan.device()
|
device = await bsblan.device()
|
||||||
self.mac = device.MAC
|
retrieved_mac = device.MAC
|
||||||
|
|
||||||
await self.async_set_unique_id(
|
# Handle unique ID assignment based on whether MAC was available from zeroconf
|
||||||
format_mac(self.mac), raise_on_progress=raise_on_progress
|
if not self.mac:
|
||||||
)
|
# MAC wasn't available from zeroconf, now we have it from API
|
||||||
|
self.mac = retrieved_mac
|
||||||
|
await self.async_set_unique_id(
|
||||||
|
format_mac(self.mac), raise_on_progress=raise_on_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always allow updating host/port for both user and discovery flows
|
||||||
|
# This ensures connectivity is maintained when devices change IP addresses
|
||||||
self._abort_if_unique_id_configured(
|
self._abort_if_unique_id_configured(
|
||||||
updates={
|
updates={
|
||||||
CONF_HOST: self.host,
|
CONF_HOST: self.host,
|
||||||
|
@ -7,5 +7,11 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["bsblan"],
|
"loggers": ["bsblan"],
|
||||||
"requirements": ["python-bsblan==2.1.0"]
|
"requirements": ["python-bsblan==2.1.0"],
|
||||||
|
"zeroconf": [
|
||||||
|
{
|
||||||
|
"type": "_http._tcp.local.",
|
||||||
|
"name": "bsb-lan*"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,8 @@ from . import BSBLanConfigEntry, BSBLanData
|
|||||||
from .coordinator import BSBLanCoordinatorData
|
from .coordinator import BSBLanCoordinatorData
|
||||||
from .entity import BSBLanEntity
|
from .entity import BSBLanEntity
|
||||||
|
|
||||||
|
PARALLEL_UPDATES = 1
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, kw_only=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class BSBLanSensorEntityDescription(SensorEntityDescription):
|
class BSBLanSensorEntityDescription(SensorEntityDescription):
|
||||||
|
@ -13,7 +13,25 @@
|
|||||||
"password": "[%key:common::config_flow::data::password%]"
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"host": "The hostname or IP address of your BSB-Lan device."
|
"host": "The hostname or IP address of your BSB-Lan device.",
|
||||||
|
"port": "The port number of your BSB-Lan device.",
|
||||||
|
"passkey": "The passkey for your BSB-Lan device.",
|
||||||
|
"username": "The username for your BSB-Lan device.",
|
||||||
|
"password": "The password for your BSB-Lan device."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"discovery_confirm": {
|
||||||
|
"title": "BSB-Lan device discovered",
|
||||||
|
"description": "A BSB-Lan device was discovered at {host}. Please provide credentials if required.",
|
||||||
|
"data": {
|
||||||
|
"passkey": "[%key:component::bsblan::config::step::user::data::passkey%]",
|
||||||
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
|
"password": "[%key:common::config_flow::data::password%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"passkey": "[%key:component::bsblan::config::step::user::data_description::passkey%]",
|
||||||
|
"username": "[%key:component::bsblan::config::step::user::data_description::username%]",
|
||||||
|
"password": "[%key:component::bsblan::config::step::user::data_description::password%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -34,6 +34,7 @@ from .agent_manager import (
|
|||||||
from .chat_log import (
|
from .chat_log import (
|
||||||
AssistantContent,
|
AssistantContent,
|
||||||
AssistantContentDeltaDict,
|
AssistantContentDeltaDict,
|
||||||
|
Attachment,
|
||||||
ChatLog,
|
ChatLog,
|
||||||
Content,
|
Content,
|
||||||
ConverseError,
|
ConverseError,
|
||||||
@ -66,6 +67,7 @@ __all__ = [
|
|||||||
"HOME_ASSISTANT_AGENT",
|
"HOME_ASSISTANT_AGENT",
|
||||||
"AssistantContent",
|
"AssistantContent",
|
||||||
"AssistantContentDeltaDict",
|
"AssistantContentDeltaDict",
|
||||||
|
"Attachment",
|
||||||
"ChatLog",
|
"ChatLog",
|
||||||
"Content",
|
"Content",
|
||||||
"ConversationEntity",
|
"ConversationEntity",
|
||||||
|
@ -8,6 +8,7 @@ from contextlib import contextmanager
|
|||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import asdict, dataclass, field, replace
|
from dataclasses import asdict, dataclass, field, replace
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypedDict
|
from typing import Any, Literal, TypedDict
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@ -136,6 +137,21 @@ class UserContent:
|
|||||||
|
|
||||||
role: Literal["user"] = field(init=False, default="user")
|
role: Literal["user"] = field(init=False, default="user")
|
||||||
content: str
|
content: str
|
||||||
|
attachments: list[Attachment] | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Attachment:
|
||||||
|
"""Attachment for a chat message."""
|
||||||
|
|
||||||
|
media_content_id: str
|
||||||
|
"""Media content ID of the attachment."""
|
||||||
|
|
||||||
|
mime_type: str
|
||||||
|
"""MIME type of the attachment."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
"""Path to the attachment on disk."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_SOURCE, Platform
|
from homeassistant.const import CONF_SOURCE, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -9,12 +11,18 @@ from homeassistant.helpers.device import (
|
|||||||
async_entity_id_to_device_id,
|
async_entity_id_to_device_id,
|
||||||
async_remove_stale_devices_links_keep_entity_device,
|
async_remove_stale_devices_links_keep_entity_device,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
from homeassistant.helpers.helper_integration import (
|
||||||
|
async_handle_source_entity_changes,
|
||||||
|
async_remove_helper_config_entry_from_source_device,
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Derivative from a config entry."""
|
"""Set up Derivative from a config entry."""
|
||||||
|
|
||||||
|
# This can be removed in HA Core 2026.2
|
||||||
async_remove_stale_devices_links_keep_entity_device(
|
async_remove_stale_devices_links_keep_entity_device(
|
||||||
hass, entry.entry_id, entry.options[CONF_SOURCE]
|
hass, entry.entry_id, entry.options[CONF_SOURCE]
|
||||||
)
|
)
|
||||||
@ -25,20 +33,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
options={**entry.options, CONF_SOURCE: source_entity_id},
|
options={**entry.options, CONF_SOURCE: source_entity_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def source_entity_removed() -> None:
|
|
||||||
# The source entity has been removed, we need to clean the device links.
|
|
||||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
|
||||||
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
async_handle_source_entity_changes(
|
async_handle_source_entity_changes(
|
||||||
hass,
|
hass,
|
||||||
|
add_helper_config_entry_to_device=False,
|
||||||
helper_config_entry_id=entry.entry_id,
|
helper_config_entry_id=entry.entry_id,
|
||||||
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
||||||
source_device_id=async_entity_id_to_device_id(
|
source_device_id=async_entity_id_to_device_id(
|
||||||
hass, entry.options[CONF_SOURCE]
|
hass, entry.options[CONF_SOURCE]
|
||||||
),
|
),
|
||||||
source_entity_id_or_uuid=entry.options[CONF_SOURCE],
|
source_entity_id_or_uuid=entry.options[CONF_SOURCE],
|
||||||
source_entity_removed=source_entity_removed,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
|
await hass.config_entries.async_forward_entry_setups(entry, (Platform.SENSOR,))
|
||||||
@ -54,3 +58,51 @@ async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry)
|
|||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
|
return await hass.config_entries.async_unload_platforms(entry, (Platform.SENSOR,))
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating configuration from version %s.%s",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_entry.version > 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
return False
|
||||||
|
|
||||||
|
if config_entry.version == 1:
|
||||||
|
if config_entry.minor_version < 2:
|
||||||
|
new_options = {**config_entry.options}
|
||||||
|
|
||||||
|
if new_options.get("unit_prefix") == "none":
|
||||||
|
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||||
|
del new_options["unit_prefix"]
|
||||||
|
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=new_options, version=1, minor_version=2
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_entry.minor_version < 3:
|
||||||
|
# Remove the derivative config entry from the source device
|
||||||
|
if source_device_id := async_entity_id_to_device_id(
|
||||||
|
hass, config_entry.options[CONF_SOURCE]
|
||||||
|
):
|
||||||
|
async_remove_helper_config_entry_from_source_device(
|
||||||
|
hass,
|
||||||
|
helper_config_entry_id=config_entry.entry_id,
|
||||||
|
source_device_id=source_device_id,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, version=1, minor_version=3
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to configuration version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
@ -141,6 +141,9 @@ class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
|||||||
config_flow = CONFIG_FLOW
|
config_flow = CONFIG_FLOW
|
||||||
options_flow = OPTIONS_FLOW
|
options_flow = OPTIONS_FLOW
|
||||||
|
|
||||||
|
VERSION = 1
|
||||||
|
MINOR_VERSION = 3
|
||||||
|
|
||||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
||||||
"""Return config entry title."""
|
"""Return config entry title."""
|
||||||
return cast(str, options[CONF_NAME])
|
return cast(str, options[CONF_NAME])
|
||||||
|
@ -34,8 +34,7 @@ from homeassistant.core import (
|
|||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
from homeassistant.helpers.device import async_entity_id_to_device
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import (
|
||||||
AddConfigEntryEntitiesCallback,
|
AddConfigEntryEntitiesCallback,
|
||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
@ -118,30 +117,21 @@ async def async_setup_entry(
|
|||||||
registry, config_entry.options[CONF_SOURCE]
|
registry, config_entry.options[CONF_SOURCE]
|
||||||
)
|
)
|
||||||
|
|
||||||
device_info = async_device_info_to_link_from_entity(
|
|
||||||
hass,
|
|
||||||
source_entity_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none":
|
|
||||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
|
||||||
unit_prefix = None
|
|
||||||
|
|
||||||
if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
|
if max_sub_interval_dict := config_entry.options.get(CONF_MAX_SUB_INTERVAL, None):
|
||||||
max_sub_interval = cv.time_period(max_sub_interval_dict)
|
max_sub_interval = cv.time_period(max_sub_interval_dict)
|
||||||
else:
|
else:
|
||||||
max_sub_interval = None
|
max_sub_interval = None
|
||||||
|
|
||||||
derivative_sensor = DerivativeSensor(
|
derivative_sensor = DerivativeSensor(
|
||||||
|
hass,
|
||||||
name=config_entry.title,
|
name=config_entry.title,
|
||||||
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
|
round_digits=int(config_entry.options[CONF_ROUND_DIGITS]),
|
||||||
source_entity=source_entity_id,
|
source_entity=source_entity_id,
|
||||||
time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]),
|
time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]),
|
||||||
unique_id=config_entry.entry_id,
|
unique_id=config_entry.entry_id,
|
||||||
unit_of_measurement=None,
|
unit_of_measurement=None,
|
||||||
unit_prefix=unit_prefix,
|
unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX),
|
||||||
unit_time=config_entry.options[CONF_UNIT_TIME],
|
unit_time=config_entry.options[CONF_UNIT_TIME],
|
||||||
device_info=device_info,
|
|
||||||
max_sub_interval=max_sub_interval,
|
max_sub_interval=max_sub_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -156,6 +146,7 @@ async def async_setup_platform(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the derivative sensor."""
|
"""Set up the derivative sensor."""
|
||||||
derivative = DerivativeSensor(
|
derivative = DerivativeSensor(
|
||||||
|
hass,
|
||||||
name=config.get(CONF_NAME),
|
name=config.get(CONF_NAME),
|
||||||
round_digits=config[CONF_ROUND_DIGITS],
|
round_digits=config[CONF_ROUND_DIGITS],
|
||||||
source_entity=config[CONF_SOURCE],
|
source_entity=config[CONF_SOURCE],
|
||||||
@ -178,6 +169,7 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
name: str | None,
|
name: str | None,
|
||||||
round_digits: int,
|
round_digits: int,
|
||||||
@ -188,11 +180,13 @@ class DerivativeSensor(RestoreSensor, SensorEntity):
|
|||||||
unit_time: UnitOfTime,
|
unit_time: UnitOfTime,
|
||||||
max_sub_interval: timedelta | None,
|
max_sub_interval: timedelta | None,
|
||||||
unique_id: str | None,
|
unique_id: str | None,
|
||||||
device_info: DeviceInfo | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the derivative sensor."""
|
"""Initialize the derivative sensor."""
|
||||||
self._attr_unique_id = unique_id
|
self._attr_unique_id = unique_id
|
||||||
self._attr_device_info = device_info
|
self.device_entry = async_entity_id_to_device(
|
||||||
|
hass,
|
||||||
|
source_entity,
|
||||||
|
)
|
||||||
self._sensor_source_id = source_entity
|
self._sensor_source_id = source_entity
|
||||||
self._round_digits = round_digits
|
self._round_digits = round_digits
|
||||||
self._attr_native_value = round(Decimal(0), round_digits)
|
self._attr_native_value = round(Decimal(0), round_digits)
|
||||||
|
@ -25,7 +25,8 @@ PLATFORMS: list[Platform] = [Platform.TTS]
|
|||||||
|
|
||||||
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
|
async def get_model_by_id(client: AsyncElevenLabs, model_id: str) -> Model | None:
|
||||||
"""Get ElevenLabs model from their API by the model_id."""
|
"""Get ElevenLabs model from their API by the model_id."""
|
||||||
models = await client.models.get_all()
|
models = await client.models.list()
|
||||||
|
|
||||||
for maybe_model in models:
|
for maybe_model in models:
|
||||||
if maybe_model.model_id == model_id:
|
if maybe_model.model_id == model_id:
|
||||||
return maybe_model
|
return maybe_model
|
||||||
|
@ -23,14 +23,12 @@ from . import ElevenLabsConfigEntry
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_CONFIGURE_VOICE,
|
CONF_CONFIGURE_VOICE,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
CONF_OPTIMIZE_LATENCY,
|
|
||||||
CONF_SIMILARITY,
|
CONF_SIMILARITY,
|
||||||
CONF_STABILITY,
|
CONF_STABILITY,
|
||||||
CONF_STYLE,
|
CONF_STYLE,
|
||||||
CONF_USE_SPEAKER_BOOST,
|
CONF_USE_SPEAKER_BOOST,
|
||||||
CONF_VOICE,
|
CONF_VOICE,
|
||||||
DEFAULT_MODEL,
|
DEFAULT_MODEL,
|
||||||
DEFAULT_OPTIMIZE_LATENCY,
|
|
||||||
DEFAULT_SIMILARITY,
|
DEFAULT_SIMILARITY,
|
||||||
DEFAULT_STABILITY,
|
DEFAULT_STABILITY,
|
||||||
DEFAULT_STYLE,
|
DEFAULT_STYLE,
|
||||||
@ -51,7 +49,8 @@ async def get_voices_models(
|
|||||||
httpx_client = get_async_client(hass)
|
httpx_client = get_async_client(hass)
|
||||||
client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client)
|
client = AsyncElevenLabs(api_key=api_key, httpx_client=httpx_client)
|
||||||
voices = (await client.voices.get_all()).voices
|
voices = (await client.voices.get_all()).voices
|
||||||
models = await client.models.get_all()
|
models = await client.models.list()
|
||||||
|
|
||||||
voices_dict = {
|
voices_dict = {
|
||||||
voice.voice_id: voice.name
|
voice.voice_id: voice.name
|
||||||
for voice in sorted(voices, key=lambda v: v.name or "")
|
for voice in sorted(voices, key=lambda v: v.name or "")
|
||||||
@ -78,8 +77,13 @@ class ElevenLabsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY])
|
voices, _ = await get_voices_models(self.hass, user_input[CONF_API_KEY])
|
||||||
except ApiError:
|
except ApiError as exc:
|
||||||
errors["base"] = "invalid_api_key"
|
errors["base"] = "unknown"
|
||||||
|
details = getattr(exc, "body", {}).get("detail", {})
|
||||||
|
if details:
|
||||||
|
status = details.get("status")
|
||||||
|
if status == "invalid_api_key":
|
||||||
|
errors["base"] = "invalid_api_key"
|
||||||
else:
|
else:
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title="ElevenLabs",
|
title="ElevenLabs",
|
||||||
@ -206,12 +210,6 @@ class ElevenLabsOptionsFlow(OptionsFlow):
|
|||||||
vol.Coerce(float),
|
vol.Coerce(float),
|
||||||
vol.Range(min=0, max=1),
|
vol.Range(min=0, max=1),
|
||||||
),
|
),
|
||||||
vol.Optional(
|
|
||||||
CONF_OPTIMIZE_LATENCY,
|
|
||||||
default=self.config_entry.options.get(
|
|
||||||
CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY
|
|
||||||
),
|
|
||||||
): vol.All(int, vol.Range(min=0, max=4)),
|
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_STYLE,
|
CONF_STYLE,
|
||||||
default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE),
|
default=self.config_entry.options.get(CONF_STYLE, DEFAULT_STYLE),
|
||||||
|
@ -7,7 +7,6 @@ CONF_MODEL = "model"
|
|||||||
CONF_CONFIGURE_VOICE = "configure_voice"
|
CONF_CONFIGURE_VOICE = "configure_voice"
|
||||||
CONF_STABILITY = "stability"
|
CONF_STABILITY = "stability"
|
||||||
CONF_SIMILARITY = "similarity"
|
CONF_SIMILARITY = "similarity"
|
||||||
CONF_OPTIMIZE_LATENCY = "optimize_streaming_latency"
|
|
||||||
CONF_STYLE = "style"
|
CONF_STYLE = "style"
|
||||||
CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
|
CONF_USE_SPEAKER_BOOST = "use_speaker_boost"
|
||||||
DOMAIN = "elevenlabs"
|
DOMAIN = "elevenlabs"
|
||||||
@ -15,6 +14,5 @@ DOMAIN = "elevenlabs"
|
|||||||
DEFAULT_MODEL = "eleven_multilingual_v2"
|
DEFAULT_MODEL = "eleven_multilingual_v2"
|
||||||
DEFAULT_STABILITY = 0.5
|
DEFAULT_STABILITY = 0.5
|
||||||
DEFAULT_SIMILARITY = 0.75
|
DEFAULT_SIMILARITY = 0.75
|
||||||
DEFAULT_OPTIMIZE_LATENCY = 0
|
|
||||||
DEFAULT_STYLE = 0
|
DEFAULT_STYLE = 0
|
||||||
DEFAULT_USE_SPEAKER_BOOST = True
|
DEFAULT_USE_SPEAKER_BOOST = True
|
||||||
|
@ -7,5 +7,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["elevenlabs"],
|
"loggers": ["elevenlabs"],
|
||||||
"requirements": ["elevenlabs==1.9.0"]
|
"requirements": ["elevenlabs==2.3.0"]
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]"
|
"invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]",
|
||||||
|
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
@ -32,14 +33,12 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"stability": "Stability",
|
"stability": "Stability",
|
||||||
"similarity": "Similarity",
|
"similarity": "Similarity",
|
||||||
"optimize_streaming_latency": "Latency",
|
|
||||||
"style": "Style",
|
"style": "Style",
|
||||||
"use_speaker_boost": "Speaker boost"
|
"use_speaker_boost": "Speaker boost"
|
||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"stability": "Stability of the generated audio. Higher values lead to less emotional audio.",
|
"stability": "Stability of the generated audio. Higher values lead to less emotional audio.",
|
||||||
"similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.",
|
"similarity": "Similarity of the generated audio to the original voice. Higher values may result in more similar audio, but may also introduce background noise.",
|
||||||
"optimize_streaming_latency": "Optimize the model for streaming. This may reduce the quality of the generated audio.",
|
|
||||||
"style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.",
|
"style": "Style of the generated audio. Recommended to keep at 0 for most almost all use cases.",
|
||||||
"use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice."
|
"use_speaker_boost": "Use speaker boost to increase the similarity of the generated audio to the original voice."
|
||||||
}
|
}
|
||||||
|
@ -25,13 +25,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from . import ElevenLabsConfigEntry
|
from . import ElevenLabsConfigEntry
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_MODEL,
|
ATTR_MODEL,
|
||||||
CONF_OPTIMIZE_LATENCY,
|
|
||||||
CONF_SIMILARITY,
|
CONF_SIMILARITY,
|
||||||
CONF_STABILITY,
|
CONF_STABILITY,
|
||||||
CONF_STYLE,
|
CONF_STYLE,
|
||||||
CONF_USE_SPEAKER_BOOST,
|
CONF_USE_SPEAKER_BOOST,
|
||||||
CONF_VOICE,
|
CONF_VOICE,
|
||||||
DEFAULT_OPTIMIZE_LATENCY,
|
|
||||||
DEFAULT_SIMILARITY,
|
DEFAULT_SIMILARITY,
|
||||||
DEFAULT_STABILITY,
|
DEFAULT_STABILITY,
|
||||||
DEFAULT_STYLE,
|
DEFAULT_STYLE,
|
||||||
@ -75,9 +73,6 @@ async def async_setup_entry(
|
|||||||
config_entry.entry_id,
|
config_entry.entry_id,
|
||||||
config_entry.title,
|
config_entry.title,
|
||||||
voice_settings,
|
voice_settings,
|
||||||
config_entry.options.get(
|
|
||||||
CONF_OPTIMIZE_LATENCY, DEFAULT_OPTIMIZE_LATENCY
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -98,7 +93,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
|||||||
entry_id: str,
|
entry_id: str,
|
||||||
title: str,
|
title: str,
|
||||||
voice_settings: VoiceSettings,
|
voice_settings: VoiceSettings,
|
||||||
latency: int = 0,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Init ElevenLabs TTS service."""
|
"""Init ElevenLabs TTS service."""
|
||||||
self._client = client
|
self._client = client
|
||||||
@ -115,7 +109,6 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
|||||||
if voice_indices:
|
if voice_indices:
|
||||||
self._voices.insert(0, self._voices.pop(voice_indices[0]))
|
self._voices.insert(0, self._voices.pop(voice_indices[0]))
|
||||||
self._voice_settings = voice_settings
|
self._voice_settings = voice_settings
|
||||||
self._latency = latency
|
|
||||||
|
|
||||||
# Entity attributes
|
# Entity attributes
|
||||||
self._attr_unique_id = entry_id
|
self._attr_unique_id = entry_id
|
||||||
@ -144,14 +137,14 @@ class ElevenLabsTTSEntity(TextToSpeechEntity):
|
|||||||
voice_id = options.get(ATTR_VOICE, self._default_voice_id)
|
voice_id = options.get(ATTR_VOICE, self._default_voice_id)
|
||||||
model = options.get(ATTR_MODEL, self._model.model_id)
|
model = options.get(ATTR_MODEL, self._model.model_id)
|
||||||
try:
|
try:
|
||||||
audio = await self._client.generate(
|
audio = self._client.text_to_speech.convert(
|
||||||
text=message,
|
text=message,
|
||||||
voice=voice_id,
|
voice_id=voice_id,
|
||||||
optimize_streaming_latency=self._latency,
|
|
||||||
voice_settings=self._voice_settings,
|
voice_settings=self._voice_settings,
|
||||||
model=model,
|
model_id=model,
|
||||||
)
|
)
|
||||||
bytes_combined = b"".join([byte_seg async for byte_seg in audio])
|
bytes_combined = b"".join([byte_seg async for byte_seg in audio])
|
||||||
|
|
||||||
except ApiError as exc:
|
except ApiError as exc:
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Error during processing of TTS request %s", exc, exc_info=True
|
"Error during processing of TTS request %s", exc, exc_info=True
|
||||||
|
@ -22,5 +22,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["eq3btsmart"],
|
"loggers": ["eq3btsmart"],
|
||||||
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==2.16.0"]
|
"requirements": ["eq3btsmart==2.1.0", "bleak-esphome==3.1.0"]
|
||||||
}
|
}
|
||||||
|
@ -100,49 +100,70 @@ class EsphomeAlarmControlPanel(
|
|||||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||||
"""Send disarm command."""
|
"""Send disarm command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.DISARM, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.DISARM,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||||
"""Send arm home command."""
|
"""Send arm home command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.ARM_HOME, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.ARM_HOME,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.ARM_AWAY, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.ARM_AWAY,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.ARM_NIGHT, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.ARM_NIGHT,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
|
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.ARM_CUSTOM_BYPASS, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.ARM_CUSTOM_BYPASS,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.ARM_VACATION, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.ARM_VACATION,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||||
"""Send alarm trigger command."""
|
"""Send alarm trigger command."""
|
||||||
self._client.alarm_control_panel_command(
|
self._client.alarm_control_panel_command(
|
||||||
self._key, AlarmControlPanelCommand.TRIGGER, code
|
self._key,
|
||||||
|
AlarmControlPanelCommand.TRIGGER,
|
||||||
|
code,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -48,7 +48,7 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Press the button."""
|
"""Press the button."""
|
||||||
self._client.button_command(self._key)
|
self._client.button_command(self._key, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -287,18 +287,24 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
|||||||
data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
|
data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
|
||||||
if ATTR_TARGET_TEMP_HIGH in kwargs:
|
if ATTR_TARGET_TEMP_HIGH in kwargs:
|
||||||
data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH]
|
data["target_temperature_high"] = kwargs[ATTR_TARGET_TEMP_HIGH]
|
||||||
self._client.climate_command(**data)
|
self._client.climate_command(**data, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_humidity(self, humidity: int) -> None:
|
async def async_set_humidity(self, humidity: int) -> None:
|
||||||
"""Set new target humidity."""
|
"""Set new target humidity."""
|
||||||
self._client.climate_command(key=self._key, target_humidity=humidity)
|
self._client.climate_command(
|
||||||
|
key=self._key,
|
||||||
|
target_humidity=humidity,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
"""Set new target operation mode."""
|
"""Set new target operation mode."""
|
||||||
self._client.climate_command(
|
self._client.climate_command(
|
||||||
key=self._key, mode=_CLIMATE_MODES.from_hass(hvac_mode)
|
key=self._key,
|
||||||
|
mode=_CLIMATE_MODES.from_hass(hvac_mode),
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
@ -309,7 +315,7 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
|||||||
kwargs["custom_preset"] = preset_mode
|
kwargs["custom_preset"] = preset_mode
|
||||||
else:
|
else:
|
||||||
kwargs["preset"] = _PRESETS.from_hass(preset_mode)
|
kwargs["preset"] = _PRESETS.from_hass(preset_mode)
|
||||||
self._client.climate_command(**kwargs)
|
self._client.climate_command(**kwargs, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
async def async_set_fan_mode(self, fan_mode: str) -> None:
|
||||||
@ -319,13 +325,15 @@ class EsphomeClimateEntity(EsphomeEntity[ClimateInfo, ClimateState], ClimateEnti
|
|||||||
kwargs["custom_fan_mode"] = fan_mode
|
kwargs["custom_fan_mode"] = fan_mode
|
||||||
else:
|
else:
|
||||||
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
|
kwargs["fan_mode"] = _FAN_MODES.from_hass(fan_mode)
|
||||||
self._client.climate_command(**kwargs)
|
self._client.climate_command(**kwargs, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||||
"""Set new swing mode."""
|
"""Set new swing mode."""
|
||||||
self._client.climate_command(
|
self._client.climate_command(
|
||||||
key=self._key, swing_mode=_SWING_MODES.from_hass(swing_mode)
|
key=self._key,
|
||||||
|
swing_mode=_SWING_MODES.from_hass(swing_mode),
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -90,38 +90,56 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||||
"""Open the cover."""
|
"""Open the cover."""
|
||||||
self._client.cover_command(key=self._key, position=1.0)
|
self._client.cover_command(
|
||||||
|
key=self._key, position=1.0, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||||
"""Close cover."""
|
"""Close cover."""
|
||||||
self._client.cover_command(key=self._key, position=0.0)
|
self._client.cover_command(
|
||||||
|
key=self._key, position=0.0, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_stop_cover(self, **kwargs: Any) -> None:
|
async def async_stop_cover(self, **kwargs: Any) -> None:
|
||||||
"""Stop the cover."""
|
"""Stop the cover."""
|
||||||
self._client.cover_command(key=self._key, stop=True)
|
self._client.cover_command(
|
||||||
|
key=self._key, stop=True, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
async def async_set_cover_position(self, **kwargs: Any) -> None:
|
||||||
"""Move the cover to a specific position."""
|
"""Move the cover to a specific position."""
|
||||||
self._client.cover_command(key=self._key, position=kwargs[ATTR_POSITION] / 100)
|
self._client.cover_command(
|
||||||
|
key=self._key,
|
||||||
|
position=kwargs[ATTR_POSITION] / 100,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
async def async_open_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
"""Open the cover tilt."""
|
"""Open the cover tilt."""
|
||||||
self._client.cover_command(key=self._key, tilt=1.0)
|
self._client.cover_command(
|
||||||
|
key=self._key, tilt=1.0, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
async def async_close_cover_tilt(self, **kwargs: Any) -> None:
|
||||||
"""Close the cover tilt."""
|
"""Close the cover tilt."""
|
||||||
self._client.cover_command(key=self._key, tilt=0.0)
|
self._client.cover_command(
|
||||||
|
key=self._key, tilt=0.0, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
|
||||||
"""Move the cover tilt to a specific position."""
|
"""Move the cover tilt to a specific position."""
|
||||||
tilt_position: int = kwargs[ATTR_TILT_POSITION]
|
tilt_position: int = kwargs[ATTR_TILT_POSITION]
|
||||||
self._client.cover_command(key=self._key, tilt=tilt_position / 100)
|
self._client.cover_command(
|
||||||
|
key=self._key,
|
||||||
|
tilt=tilt_position / 100,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -28,7 +28,13 @@ class EsphomeDate(EsphomeEntity[DateInfo, DateState], DateEntity):
|
|||||||
|
|
||||||
async def async_set_value(self, value: date) -> None:
|
async def async_set_value(self, value: date) -> None:
|
||||||
"""Update the current date."""
|
"""Update the current date."""
|
||||||
self._client.date_command(self._key, value.year, value.month, value.day)
|
self._client.date_command(
|
||||||
|
self._key,
|
||||||
|
value.year,
|
||||||
|
value.month,
|
||||||
|
value.day,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -29,7 +29,9 @@ class EsphomeDateTime(EsphomeEntity[DateTimeInfo, DateTimeState], DateTimeEntity
|
|||||||
|
|
||||||
async def async_set_value(self, value: datetime) -> None:
|
async def async_set_value(self, value: datetime) -> None:
|
||||||
"""Update the current datetime."""
|
"""Update the current datetime."""
|
||||||
self._client.datetime_command(self._key, int(value.timestamp()))
|
self._client.datetime_command(
|
||||||
|
self._key, int(value.timestamp()), device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -71,7 +71,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
|||||||
ORDERED_NAMED_FAN_SPEEDS, percentage
|
ORDERED_NAMED_FAN_SPEEDS, percentage
|
||||||
)
|
)
|
||||||
data["speed"] = named_speed
|
data["speed"] = named_speed
|
||||||
self._client.fan_command(**data)
|
self._client.fan_command(**data, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
async def async_turn_on(
|
async def async_turn_on(
|
||||||
self,
|
self,
|
||||||
@ -85,24 +85,36 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn off the fan."""
|
"""Turn off the fan."""
|
||||||
self._client.fan_command(key=self._key, state=False)
|
self._client.fan_command(
|
||||||
|
key=self._key, state=False, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_oscillate(self, oscillating: bool) -> None:
|
async def async_oscillate(self, oscillating: bool) -> None:
|
||||||
"""Oscillate the fan."""
|
"""Oscillate the fan."""
|
||||||
self._client.fan_command(key=self._key, oscillating=oscillating)
|
self._client.fan_command(
|
||||||
|
key=self._key,
|
||||||
|
oscillating=oscillating,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_direction(self, direction: str) -> None:
|
async def async_set_direction(self, direction: str) -> None:
|
||||||
"""Set direction of the fan."""
|
"""Set direction of the fan."""
|
||||||
self._client.fan_command(
|
self._client.fan_command(
|
||||||
key=self._key, direction=_FAN_DIRECTIONS.from_hass(direction)
|
key=self._key,
|
||||||
|
direction=_FAN_DIRECTIONS.from_hass(direction),
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
"""Set the preset mode of the fan."""
|
"""Set the preset mode of the fan."""
|
||||||
self._client.fan_command(key=self._key, preset_mode=preset_mode)
|
self._client.fan_command(
|
||||||
|
key=self._key,
|
||||||
|
preset_mode=preset_mode,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
|
@ -280,7 +280,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
|||||||
# (fewest capabilities set)
|
# (fewest capabilities set)
|
||||||
data["color_mode"] = _least_complex_color_mode(color_modes)
|
data["color_mode"] = _least_complex_color_mode(color_modes)
|
||||||
|
|
||||||
self._client.light_command(**data)
|
self._client.light_command(**data, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
@ -290,7 +290,7 @@ class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity):
|
|||||||
data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
|
data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
|
||||||
if ATTR_TRANSITION in kwargs:
|
if ATTR_TRANSITION in kwargs:
|
||||||
data["transition_length"] = kwargs[ATTR_TRANSITION]
|
data["transition_length"] = kwargs[ATTR_TRANSITION]
|
||||||
self._client.light_command(**data)
|
self._client.light_command(**data, device_id=self._static_info.device_id)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@esphome_state_property
|
@esphome_state_property
|
||||||
|
@ -65,18 +65,24 @@ class EsphomeLock(EsphomeEntity[LockInfo, LockEntityState], LockEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_lock(self, **kwargs: Any) -> None:
|
async def async_lock(self, **kwargs: Any) -> None:
|
||||||
"""Lock the lock."""
|
"""Lock the lock."""
|
||||||
self._client.lock_command(self._key, LockCommand.LOCK)
|
self._client.lock_command(
|
||||||
|
self._key, LockCommand.LOCK, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_unlock(self, **kwargs: Any) -> None:
|
async def async_unlock(self, **kwargs: Any) -> None:
|
||||||
"""Unlock the lock."""
|
"""Unlock the lock."""
|
||||||
code = kwargs.get(ATTR_CODE)
|
code = kwargs.get(ATTR_CODE)
|
||||||
self._client.lock_command(self._key, LockCommand.UNLOCK, code)
|
self._client.lock_command(
|
||||||
|
self._key, LockCommand.UNLOCK, code, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_open(self, **kwargs: Any) -> None:
|
async def async_open(self, **kwargs: Any) -> None:
|
||||||
"""Open the door latch."""
|
"""Open the door latch."""
|
||||||
self._client.lock_command(self._key, LockCommand.OPEN)
|
self._client.lock_command(
|
||||||
|
self._key, LockCommand.OPEN, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -17,9 +17,9 @@
|
|||||||
"mqtt": ["esphome/discover/#"],
|
"mqtt": ["esphome/discover/#"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==34.2.0",
|
"aioesphomeapi==35.0.0",
|
||||||
"esphome-dashboard-api==1.3.0",
|
"esphome-dashboard-api==1.3.0",
|
||||||
"bleak-esphome==2.16.0"
|
"bleak-esphome==3.1.0"
|
||||||
],
|
],
|
||||||
"zeroconf": ["_esphomelib._tcp.local."]
|
"zeroconf": ["_esphomelib._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -132,7 +132,10 @@ class EsphomeMediaPlayer(
|
|||||||
media_id = proxy_url
|
media_id = proxy_url
|
||||||
|
|
||||||
self._client.media_player_command(
|
self._client.media_player_command(
|
||||||
self._key, media_url=media_id, announcement=announcement
|
self._key,
|
||||||
|
media_url=media_id,
|
||||||
|
announcement=announcement,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
@ -214,22 +217,36 @@ class EsphomeMediaPlayer(
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_volume_level(self, volume: float) -> None:
|
async def async_set_volume_level(self, volume: float) -> None:
|
||||||
"""Set volume level, range 0..1."""
|
"""Set volume level, range 0..1."""
|
||||||
self._client.media_player_command(self._key, volume=volume)
|
self._client.media_player_command(
|
||||||
|
self._key, volume=volume, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_media_pause(self) -> None:
|
async def async_media_pause(self) -> None:
|
||||||
"""Send pause command."""
|
"""Send pause command."""
|
||||||
self._client.media_player_command(self._key, command=MediaPlayerCommand.PAUSE)
|
self._client.media_player_command(
|
||||||
|
self._key,
|
||||||
|
command=MediaPlayerCommand.PAUSE,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_media_play(self) -> None:
|
async def async_media_play(self) -> None:
|
||||||
"""Send play command."""
|
"""Send play command."""
|
||||||
self._client.media_player_command(self._key, command=MediaPlayerCommand.PLAY)
|
self._client.media_player_command(
|
||||||
|
self._key,
|
||||||
|
command=MediaPlayerCommand.PLAY,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_media_stop(self) -> None:
|
async def async_media_stop(self) -> None:
|
||||||
"""Send stop command."""
|
"""Send stop command."""
|
||||||
self._client.media_player_command(self._key, command=MediaPlayerCommand.STOP)
|
self._client.media_player_command(
|
||||||
|
self._key,
|
||||||
|
command=MediaPlayerCommand.STOP,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_mute_volume(self, mute: bool) -> None:
|
async def async_mute_volume(self, mute: bool) -> None:
|
||||||
@ -237,6 +254,7 @@ class EsphomeMediaPlayer(
|
|||||||
self._client.media_player_command(
|
self._client.media_player_command(
|
||||||
self._key,
|
self._key,
|
||||||
command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE,
|
command=MediaPlayerCommand.MUTE if mute else MediaPlayerCommand.UNMUTE,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -67,7 +67,9 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_native_value(self, value: float) -> None:
|
async def async_set_native_value(self, value: float) -> None:
|
||||||
"""Update the current value."""
|
"""Update the current value."""
|
||||||
self._client.number_command(self._key, value)
|
self._client.number_command(
|
||||||
|
self._key, value, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -76,7 +76,9 @@ class EsphomeSelect(EsphomeEntity[SelectInfo, SelectState], SelectEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_select_option(self, option: str) -> None:
|
async def async_select_option(self, option: str) -> None:
|
||||||
"""Change the selected option."""
|
"""Change the selected option."""
|
||||||
self._client.select_command(self._key, option)
|
self._client.select_command(
|
||||||
|
self._key, option, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect):
|
class EsphomeAssistPipelineSelect(EsphomeAssistEntity, AssistPipelineSelect):
|
||||||
|
@ -43,12 +43,16 @@ class EsphomeSwitch(EsphomeEntity[SwitchInfo, SwitchState], SwitchEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity on."""
|
"""Turn the entity on."""
|
||||||
self._client.switch_command(self._key, True)
|
self._client.switch_command(
|
||||||
|
self._key, True, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn the entity off."""
|
"""Turn the entity off."""
|
||||||
self._client.switch_command(self._key, False)
|
self._client.switch_command(
|
||||||
|
self._key, False, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -50,7 +50,9 @@ class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_value(self, value: str) -> None:
|
async def async_set_value(self, value: str) -> None:
|
||||||
"""Update the current value."""
|
"""Update the current value."""
|
||||||
self._client.text_command(self._key, value)
|
self._client.text_command(
|
||||||
|
self._key, value, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -28,7 +28,13 @@ class EsphomeTime(EsphomeEntity[TimeInfo, TimeState], TimeEntity):
|
|||||||
|
|
||||||
async def async_set_value(self, value: time) -> None:
|
async def async_set_value(self, value: time) -> None:
|
||||||
"""Update the current time."""
|
"""Update the current time."""
|
||||||
self._client.time_command(self._key, value.hour, value.minute, value.second)
|
self._client.time_command(
|
||||||
|
self._key,
|
||||||
|
value.hour,
|
||||||
|
value.minute,
|
||||||
|
value.second,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -334,11 +334,19 @@ class ESPHomeUpdateEntity(EsphomeEntity[UpdateInfo, UpdateState], UpdateEntity):
|
|||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Command device to check for update."""
|
"""Command device to check for update."""
|
||||||
if self.available:
|
if self.available:
|
||||||
self._client.update_command(key=self._key, command=UpdateCommand.CHECK)
|
self._client.update_command(
|
||||||
|
key=self._key,
|
||||||
|
command=UpdateCommand.CHECK,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_install(
|
async def async_install(
|
||||||
self, version: str | None, backup: bool, **kwargs: Any
|
self, version: str | None, backup: bool, **kwargs: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Command device to install update."""
|
"""Command device to install update."""
|
||||||
self._client.update_command(key=self._key, command=UpdateCommand.INSTALL)
|
self._client.update_command(
|
||||||
|
key=self._key,
|
||||||
|
command=UpdateCommand.INSTALL,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
@ -72,22 +72,32 @@ class EsphomeValve(EsphomeEntity[ValveInfo, ValveState], ValveEntity):
|
|||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_open_valve(self, **kwargs: Any) -> None:
|
async def async_open_valve(self, **kwargs: Any) -> None:
|
||||||
"""Open the valve."""
|
"""Open the valve."""
|
||||||
self._client.valve_command(key=self._key, position=1.0)
|
self._client.valve_command(
|
||||||
|
key=self._key, position=1.0, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_close_valve(self, **kwargs: Any) -> None:
|
async def async_close_valve(self, **kwargs: Any) -> None:
|
||||||
"""Close valve."""
|
"""Close valve."""
|
||||||
self._client.valve_command(key=self._key, position=0.0)
|
self._client.valve_command(
|
||||||
|
key=self._key, position=0.0, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_stop_valve(self, **kwargs: Any) -> None:
|
async def async_stop_valve(self, **kwargs: Any) -> None:
|
||||||
"""Stop the valve."""
|
"""Stop the valve."""
|
||||||
self._client.valve_command(key=self._key, stop=True)
|
self._client.valve_command(
|
||||||
|
key=self._key, stop=True, device_id=self._static_info.device_id
|
||||||
|
)
|
||||||
|
|
||||||
@convert_api_error_ha_error
|
@convert_api_error_ha_error
|
||||||
async def async_set_valve_position(self, position: float) -> None:
|
async def async_set_valve_position(self, position: float) -> None:
|
||||||
"""Move the valve to a specific position."""
|
"""Move the valve to a specific position."""
|
||||||
self._client.valve_command(key=self._key, position=position / 100)
|
self._client.valve_command(
|
||||||
|
key=self._key,
|
||||||
|
position=position / 100,
|
||||||
|
device_id=self._static_info.device_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async_setup_entry = partial(
|
async_setup_entry = partial(
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20250702.1"]
|
"requirements": ["home-assistant-frontend==20250702.2"]
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""The generic_hygrostat component."""
|
"""The generic_hygrostat component."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.components.humidifier import HumidifierDeviceClass
|
from homeassistant.components.humidifier import HumidifierDeviceClass
|
||||||
@ -16,7 +18,10 @@ from homeassistant.helpers.device import (
|
|||||||
async_remove_stale_devices_links_keep_entity_device,
|
async_remove_stale_devices_links_keep_entity_device,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
from homeassistant.helpers.helper_integration import (
|
||||||
|
async_handle_source_entity_changes,
|
||||||
|
async_remove_helper_config_entry_from_source_device,
|
||||||
|
)
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
DOMAIN = "generic_hygrostat"
|
DOMAIN = "generic_hygrostat"
|
||||||
@ -70,6 +75,8 @@ CONFIG_SCHEMA = vol.Schema(
|
|||||||
extra=vol.ALLOW_EXTRA,
|
extra=vol.ALLOW_EXTRA,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the Generic Hygrostat component."""
|
"""Set up the Generic Hygrostat component."""
|
||||||
@ -89,6 +96,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up from a config entry."""
|
"""Set up from a config entry."""
|
||||||
|
|
||||||
|
# This can be removed in HA Core 2026.2
|
||||||
async_remove_stale_devices_links_keep_entity_device(
|
async_remove_stale_devices_links_keep_entity_device(
|
||||||
hass,
|
hass,
|
||||||
entry.entry_id,
|
entry.entry_id,
|
||||||
@ -101,23 +109,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
|
options={**entry.options, CONF_HUMIDIFIER: source_entity_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def source_entity_removed() -> None:
|
|
||||||
# The source entity has been removed, we need to clean the device links.
|
|
||||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
|
||||||
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
# We use async_handle_source_entity_changes to track changes to the humidifer,
|
# We use async_handle_source_entity_changes to track changes to the humidifer,
|
||||||
# but not the humidity sensor because the generic_hygrostat adds itself to the
|
# but not the humidity sensor because the generic_hygrostat adds itself to the
|
||||||
# humidifier's device.
|
# humidifier's device.
|
||||||
async_handle_source_entity_changes(
|
async_handle_source_entity_changes(
|
||||||
hass,
|
hass,
|
||||||
|
add_helper_config_entry_to_device=False,
|
||||||
helper_config_entry_id=entry.entry_id,
|
helper_config_entry_id=entry.entry_id,
|
||||||
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
||||||
source_device_id=async_entity_id_to_device_id(
|
source_device_id=async_entity_id_to_device_id(
|
||||||
hass, entry.options[CONF_HUMIDIFIER]
|
hass, entry.options[CONF_HUMIDIFIER]
|
||||||
),
|
),
|
||||||
source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER],
|
source_entity_id_or_uuid=entry.options[CONF_HUMIDIFIER],
|
||||||
source_entity_removed=source_entity_removed,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -148,6 +152,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_entry.version > 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
return False
|
||||||
|
if config_entry.version == 1:
|
||||||
|
options = {**config_entry.options}
|
||||||
|
if config_entry.minor_version < 2:
|
||||||
|
# Remove the generic_hygrostat config entry from the source device
|
||||||
|
if source_device_id := async_entity_id_to_device_id(
|
||||||
|
hass, options[CONF_HUMIDIFIER]
|
||||||
|
):
|
||||||
|
async_remove_helper_config_entry_from_source_device(
|
||||||
|
hass,
|
||||||
|
helper_config_entry_id=config_entry.entry_id,
|
||||||
|
source_device_id=source_device_id,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, minor_version=2
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Update listener, called when the config entry options are changed."""
|
"""Update listener, called when the config entry options are changed."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@ -92,6 +92,8 @@ OPTIONS_FLOW = {
|
|||||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||||
"""Handle a config or options flow."""
|
"""Handle a config or options flow."""
|
||||||
|
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
config_flow = CONFIG_FLOW
|
config_flow = CONFIG_FLOW
|
||||||
options_flow = OPTIONS_FLOW
|
options_flow = OPTIONS_FLOW
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ from homeassistant.core import (
|
|||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers import condition, config_validation as cv
|
from homeassistant.helpers import condition, config_validation as cv
|
||||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
from homeassistant.helpers.device import async_entity_id_to_device
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import (
|
||||||
AddConfigEntryEntitiesCallback,
|
AddConfigEntryEntitiesCallback,
|
||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
@ -145,22 +145,22 @@ async def _async_setup_config(
|
|||||||
[
|
[
|
||||||
GenericHygrostat(
|
GenericHygrostat(
|
||||||
hass,
|
hass,
|
||||||
name,
|
name=name,
|
||||||
switch_entity_id,
|
switch_entity_id=switch_entity_id,
|
||||||
sensor_entity_id,
|
sensor_entity_id=sensor_entity_id,
|
||||||
min_humidity,
|
min_humidity=min_humidity,
|
||||||
max_humidity,
|
max_humidity=max_humidity,
|
||||||
target_humidity,
|
target_humidity=target_humidity,
|
||||||
device_class,
|
device_class=device_class,
|
||||||
min_cycle_duration,
|
min_cycle_duration=min_cycle_duration,
|
||||||
dry_tolerance,
|
dry_tolerance=dry_tolerance,
|
||||||
wet_tolerance,
|
wet_tolerance=wet_tolerance,
|
||||||
keep_alive,
|
keep_alive=keep_alive,
|
||||||
initial_state,
|
initial_state=initial_state,
|
||||||
away_humidity,
|
away_humidity=away_humidity,
|
||||||
away_fixed,
|
away_fixed=away_fixed,
|
||||||
sensor_stale_duration,
|
sensor_stale_duration=sensor_stale_duration,
|
||||||
unique_id,
|
unique_id=unique_id,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -174,6 +174,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
switch_entity_id: str,
|
switch_entity_id: str,
|
||||||
sensor_entity_id: str,
|
sensor_entity_id: str,
|
||||||
@ -195,7 +196,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity):
|
|||||||
self._name = name
|
self._name = name
|
||||||
self._switch_entity_id = switch_entity_id
|
self._switch_entity_id = switch_entity_id
|
||||||
self._sensor_entity_id = sensor_entity_id
|
self._sensor_entity_id = sensor_entity_id
|
||||||
self._attr_device_info = async_device_info_to_link_from_entity(
|
self.device_entry = async_entity_id_to_device(
|
||||||
hass,
|
hass,
|
||||||
switch_entity_id,
|
switch_entity_id,
|
||||||
)
|
)
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""The generic_thermostat component."""
|
"""The generic_thermostat component."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import Event, HomeAssistant
|
from homeassistant.core import Event, HomeAssistant
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import entity_registry as er
|
||||||
@ -8,14 +10,20 @@ from homeassistant.helpers.device import (
|
|||||||
async_remove_stale_devices_links_keep_entity_device,
|
async_remove_stale_devices_links_keep_entity_device,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
from homeassistant.helpers.event import async_track_entity_registry_updated_event
|
||||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
from homeassistant.helpers.helper_integration import (
|
||||||
|
async_handle_source_entity_changes,
|
||||||
|
async_remove_helper_config_entry_from_source_device,
|
||||||
|
)
|
||||||
|
|
||||||
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
|
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up from a config entry."""
|
"""Set up from a config entry."""
|
||||||
|
|
||||||
|
# This can be removed in HA Core 2026.2
|
||||||
async_remove_stale_devices_links_keep_entity_device(
|
async_remove_stale_devices_links_keep_entity_device(
|
||||||
hass,
|
hass,
|
||||||
entry.entry_id,
|
entry.entry_id,
|
||||||
@ -28,23 +36,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
options={**entry.options, CONF_HEATER: source_entity_id},
|
options={**entry.options, CONF_HEATER: source_entity_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def source_entity_removed() -> None:
|
|
||||||
# The source entity has been removed, we need to clean the device links.
|
|
||||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
|
||||||
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
# We use async_handle_source_entity_changes to track changes to the heater, but
|
# We use async_handle_source_entity_changes to track changes to the heater, but
|
||||||
# not the temperature sensor because the generic_hygrostat adds itself to the
|
# not the temperature sensor because the generic_hygrostat adds itself to the
|
||||||
# heater's device.
|
# heater's device.
|
||||||
async_handle_source_entity_changes(
|
async_handle_source_entity_changes(
|
||||||
hass,
|
hass,
|
||||||
|
add_helper_config_entry_to_device=False,
|
||||||
helper_config_entry_id=entry.entry_id,
|
helper_config_entry_id=entry.entry_id,
|
||||||
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
set_source_entity_id_or_uuid=set_humidifier_entity_id_or_uuid,
|
||||||
source_device_id=async_entity_id_to_device_id(
|
source_device_id=async_entity_id_to_device_id(
|
||||||
hass, entry.options[CONF_HEATER]
|
hass, entry.options[CONF_HEATER]
|
||||||
),
|
),
|
||||||
source_entity_id_or_uuid=entry.options[CONF_HEATER],
|
source_entity_id_or_uuid=entry.options[CONF_HEATER],
|
||||||
source_entity_removed=source_entity_removed,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -75,6 +79,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_entry.version > 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
return False
|
||||||
|
if config_entry.version == 1:
|
||||||
|
options = {**config_entry.options}
|
||||||
|
if config_entry.minor_version < 2:
|
||||||
|
# Remove the generic_thermostat config entry from the source device
|
||||||
|
if source_device_id := async_entity_id_to_device_id(
|
||||||
|
hass, options[CONF_HEATER]
|
||||||
|
):
|
||||||
|
async_remove_helper_config_entry_from_source_device(
|
||||||
|
hass,
|
||||||
|
helper_config_entry_id=config_entry.entry_id,
|
||||||
|
source_device_id=source_device_id,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, minor_version=2
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Update listener, called when the config entry options are changed."""
|
"""Update listener, called when the config entry options are changed."""
|
||||||
await hass.config_entries.async_reload(entry.entry_id)
|
await hass.config_entries.async_reload(entry.entry_id)
|
||||||
|
@ -48,7 +48,7 @@ from homeassistant.core import (
|
|||||||
)
|
)
|
||||||
from homeassistant.exceptions import ConditionError
|
from homeassistant.exceptions import ConditionError
|
||||||
from homeassistant.helpers import condition, config_validation as cv
|
from homeassistant.helpers import condition, config_validation as cv
|
||||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
from homeassistant.helpers.device import async_entity_id_to_device
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import (
|
||||||
AddConfigEntryEntitiesCallback,
|
AddConfigEntryEntitiesCallback,
|
||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
@ -182,23 +182,23 @@ async def _async_setup_config(
|
|||||||
[
|
[
|
||||||
GenericThermostat(
|
GenericThermostat(
|
||||||
hass,
|
hass,
|
||||||
name,
|
name=name,
|
||||||
heater_entity_id,
|
heater_entity_id=heater_entity_id,
|
||||||
sensor_entity_id,
|
sensor_entity_id=sensor_entity_id,
|
||||||
min_temp,
|
min_temp=min_temp,
|
||||||
max_temp,
|
max_temp=max_temp,
|
||||||
target_temp,
|
target_temp=target_temp,
|
||||||
ac_mode,
|
ac_mode=ac_mode,
|
||||||
min_cycle_duration,
|
min_cycle_duration=min_cycle_duration,
|
||||||
cold_tolerance,
|
cold_tolerance=cold_tolerance,
|
||||||
hot_tolerance,
|
hot_tolerance=hot_tolerance,
|
||||||
keep_alive,
|
keep_alive=keep_alive,
|
||||||
initial_hvac_mode,
|
initial_hvac_mode=initial_hvac_mode,
|
||||||
presets,
|
presets=presets,
|
||||||
precision,
|
precision=precision,
|
||||||
target_temperature_step,
|
target_temperature_step=target_temperature_step,
|
||||||
unit,
|
unit=unit,
|
||||||
unique_id,
|
unique_id=unique_id,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -212,6 +212,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
name: str,
|
name: str,
|
||||||
heater_entity_id: str,
|
heater_entity_id: str,
|
||||||
sensor_entity_id: str,
|
sensor_entity_id: str,
|
||||||
@ -234,7 +235,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
|
|||||||
self._attr_name = name
|
self._attr_name = name
|
||||||
self.heater_entity_id = heater_entity_id
|
self.heater_entity_id = heater_entity_id
|
||||||
self.sensor_entity_id = sensor_entity_id
|
self.sensor_entity_id = sensor_entity_id
|
||||||
self._attr_device_info = async_device_info_to_link_from_entity(
|
self.device_entry = async_entity_id_to_device(
|
||||||
hass,
|
hass,
|
||||||
heater_entity_id,
|
heater_entity_id,
|
||||||
)
|
)
|
||||||
|
@ -100,6 +100,8 @@ OPTIONS_FLOW = {
|
|||||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||||
"""Handle a config or options flow."""
|
"""Handle a config or options flow."""
|
||||||
|
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
config_flow = CONFIG_FLOW
|
config_flow = CONFIG_FLOW
|
||||||
options_flow = OPTIONS_FLOW
|
options_flow = OPTIONS_FLOW
|
||||||
|
|
||||||
|
@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
|
|||||||
try:
|
try:
|
||||||
responses = await self._client.streaming_recognize(
|
responses = await self._client.streaming_recognize(
|
||||||
requests=request_generator(),
|
requests=request_generator(),
|
||||||
timeout=10,
|
timeout=30,
|
||||||
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -218,7 +218,7 @@ class BaseGoogleCloudProvider:
|
|||||||
|
|
||||||
response = await self._client.synthesize_speech(
|
response = await self._client.synthesize_speech(
|
||||||
request,
|
request,
|
||||||
timeout=10,
|
timeout=30,
|
||||||
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -195,11 +195,15 @@ async def async_update_options(
|
|||||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||||
"""Migrate integration entry structure."""
|
"""Migrate integration entry structure."""
|
||||||
|
|
||||||
entries = hass.config_entries.async_entries(DOMAIN)
|
# Make sure we get enabled config entries first
|
||||||
|
entries = sorted(
|
||||||
|
hass.config_entries.async_entries(DOMAIN),
|
||||||
|
key=lambda e: e.disabled_by is not None,
|
||||||
|
)
|
||||||
if not any(entry.version == 1 for entry in entries):
|
if not any(entry.version == 1 for entry in entries):
|
||||||
return
|
return
|
||||||
|
|
||||||
api_keys_entries: dict[str, ConfigEntry] = {}
|
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
|
||||||
entity_registry = er.async_get(hass)
|
entity_registry = er.async_get(hass)
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
|
|
||||||
@ -213,9 +217,14 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
|||||||
)
|
)
|
||||||
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
if entry.data[CONF_API_KEY] not in api_keys_entries:
|
||||||
use_existing = True
|
use_existing = True
|
||||||
api_keys_entries[entry.data[CONF_API_KEY]] = entry
|
all_disabled = all(
|
||||||
|
e.disabled_by is not None
|
||||||
|
for e in entries
|
||||||
|
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
|
||||||
|
)
|
||||||
|
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
|
||||||
|
|
||||||
parent_entry = api_keys_entries[entry.data[CONF_API_KEY]]
|
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
|
||||||
|
|
||||||
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
hass.config_entries.async_add_subentry(parent_entry, subentry)
|
||||||
if use_existing:
|
if use_existing:
|
||||||
@ -228,25 +237,51 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
|||||||
unique_id=None,
|
unique_id=None,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conversation_entity = entity_registry.async_get_entity_id(
|
conversation_entity_id = entity_registry.async_get_entity_id(
|
||||||
"conversation",
|
"conversation",
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
entry.entry_id,
|
entry.entry_id,
|
||||||
)
|
)
|
||||||
if conversation_entity is not None:
|
|
||||||
entity_registry.async_update_entity(
|
|
||||||
conversation_entity,
|
|
||||||
config_entry_id=parent_entry.entry_id,
|
|
||||||
config_subentry_id=subentry.subentry_id,
|
|
||||||
new_unique_id=subentry.subentry_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
device = device_registry.async_get_device(
|
device = device_registry.async_get_device(
|
||||||
identifiers={(DOMAIN, entry.entry_id)}
|
identifiers={(DOMAIN, entry.entry_id)}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if conversation_entity_id is not None:
|
||||||
|
conversation_entity_entry = entity_registry.entities[conversation_entity_id]
|
||||||
|
entity_disabled_by = conversation_entity_entry.disabled_by
|
||||||
|
if (
|
||||||
|
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
|
||||||
|
and not all_disabled
|
||||||
|
):
|
||||||
|
# Device and entity registries don't update the disabled_by flag
|
||||||
|
# when moving a device or entity from one config entry to another,
|
||||||
|
# so we need to do it manually.
|
||||||
|
entity_disabled_by = (
|
||||||
|
er.RegistryEntryDisabler.DEVICE
|
||||||
|
if device
|
||||||
|
else er.RegistryEntryDisabler.USER
|
||||||
|
)
|
||||||
|
entity_registry.async_update_entity(
|
||||||
|
conversation_entity_id,
|
||||||
|
config_entry_id=parent_entry.entry_id,
|
||||||
|
config_subentry_id=subentry.subentry_id,
|
||||||
|
disabled_by=entity_disabled_by,
|
||||||
|
new_unique_id=subentry.subentry_id,
|
||||||
|
)
|
||||||
|
|
||||||
if device is not None:
|
if device is not None:
|
||||||
|
# Device and entity registries don't update the disabled_by flag when
|
||||||
|
# moving a device or entity from one config entry to another, so we
|
||||||
|
# need to do it manually.
|
||||||
|
device_disabled_by = device.disabled_by
|
||||||
|
if (
|
||||||
|
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
|
||||||
|
and not all_disabled
|
||||||
|
):
|
||||||
|
device_disabled_by = dr.DeviceEntryDisabler.USER
|
||||||
device_registry.async_update_device(
|
device_registry.async_update_device(
|
||||||
device.id,
|
device.id,
|
||||||
|
disabled_by=device_disabled_by,
|
||||||
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
new_identifiers={(DOMAIN, subentry.subentry_id)},
|
||||||
add_config_subentry_id=subentry.subentry_id,
|
add_config_subentry_id=subentry.subentry_id,
|
||||||
add_config_entry_id=parent_entry.entry_id,
|
add_config_entry_id=parent_entry.entry_id,
|
||||||
@ -266,12 +301,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
|||||||
if not use_existing:
|
if not use_existing:
|
||||||
await hass.config_entries.async_remove(entry.entry_id)
|
await hass.config_entries.async_remove(entry.entry_id)
|
||||||
else:
|
else:
|
||||||
|
_add_ai_task_subentry(hass, entry)
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry,
|
entry,
|
||||||
title=DEFAULT_TITLE,
|
title=DEFAULT_TITLE,
|
||||||
options={},
|
options={},
|
||||||
version=2,
|
version=2,
|
||||||
minor_version=2,
|
minor_version=4,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -315,19 +351,58 @@ async def async_migrate_entry(
|
|||||||
|
|
||||||
if entry.version == 2 and entry.minor_version == 2:
|
if entry.version == 2 and entry.minor_version == 2:
|
||||||
# Add AI Task subentry with default options
|
# Add AI Task subentry with default options
|
||||||
hass.config_entries.async_add_subentry(
|
_add_ai_task_subentry(hass, entry)
|
||||||
entry,
|
|
||||||
ConfigSubentry(
|
|
||||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
|
||||||
subentry_type="ai_task_data",
|
|
||||||
title=DEFAULT_AI_TASK_NAME,
|
|
||||||
unique_id=None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
hass.config_entries.async_update_entry(entry, minor_version=3)
|
hass.config_entries.async_update_entry(entry, minor_version=3)
|
||||||
|
|
||||||
|
if entry.version == 2 and entry.minor_version == 3:
|
||||||
|
# Fix migration where the disabled_by flag was not set correctly.
|
||||||
|
# We can currently only correct this for enabled config entries,
|
||||||
|
# because migration does not run for disabled config entries. This
|
||||||
|
# is asserted in tests, and if that behavior is changed, we should
|
||||||
|
# correct also disabled config entries.
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id)
|
||||||
|
entity_entries = er.async_entries_for_config_entry(
|
||||||
|
entity_registry, entry.entry_id
|
||||||
|
)
|
||||||
|
if entry.disabled_by is None:
|
||||||
|
# If the config entry is not disabled, we need to set the disabled_by
|
||||||
|
# flag on devices to USER, and on entities to DEVICE, if they are set
|
||||||
|
# to CONFIG_ENTRY.
|
||||||
|
for device in devices:
|
||||||
|
if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY:
|
||||||
|
continue
|
||||||
|
device_registry.async_update_device(
|
||||||
|
device.id,
|
||||||
|
disabled_by=dr.DeviceEntryDisabler.USER,
|
||||||
|
)
|
||||||
|
for entity in entity_entries:
|
||||||
|
if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY:
|
||||||
|
continue
|
||||||
|
entity_registry.async_update_entity(
|
||||||
|
entity.entity_id,
|
||||||
|
disabled_by=er.RegistryEntryDisabler.DEVICE,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(entry, minor_version=4)
|
||||||
|
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _add_ai_task_subentry(
|
||||||
|
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||||
|
) -> None:
|
||||||
|
"""Add AI Task subentry to the config entry."""
|
||||||
|
hass.config_entries.async_add_subentry(
|
||||||
|
entry,
|
||||||
|
ConfigSubentry(
|
||||||
|
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||||
|
subentry_type="ai_task_data",
|
||||||
|
title=DEFAULT_AI_TASK_NAME,
|
||||||
|
unique_id=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@ -37,7 +37,10 @@ class GoogleGenerativeAITaskEntity(
|
|||||||
):
|
):
|
||||||
"""Google Generative AI AI Task entity."""
|
"""Google Generative AI AI Task entity."""
|
||||||
|
|
||||||
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA
|
_attr_supported_features = (
|
||||||
|
ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||||
|
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
|
||||||
|
)
|
||||||
|
|
||||||
async def _async_generate_data(
|
async def _async_generate_data(
|
||||||
self,
|
self,
|
||||||
|
@ -97,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
"""Handle a config flow for Google Generative AI Conversation."""
|
"""Handle a config flow for Google Generative AI Conversation."""
|
||||||
|
|
||||||
VERSION = 2
|
VERSION = 2
|
||||||
MINOR_VERSION = 3
|
MINOR_VERSION = 4
|
||||||
|
|
||||||
async def async_step_api(
|
async def async_step_api(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
@ -25,7 +25,7 @@ RECOMMENDED_TOP_P = 0.95
|
|||||||
CONF_TOP_K = "top_k"
|
CONF_TOP_K = "top_k"
|
||||||
RECOMMENDED_TOP_K = 64
|
RECOMMENDED_TOP_K = 64
|
||||||
CONF_MAX_TOKENS = "max_tokens"
|
CONF_MAX_TOKENS = "max_tokens"
|
||||||
RECOMMENDED_MAX_TOKENS = 1500
|
RECOMMENDED_MAX_TOKENS = 3000
|
||||||
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
|
CONF_HARASSMENT_BLOCK_THRESHOLD = "harassment_block_threshold"
|
||||||
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
|
CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
|
||||||
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
|
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
|
||||||
|
@ -8,7 +8,7 @@ from collections.abc import AsyncGenerator, Callable
|
|||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
import mimetypes
|
import mimetypes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from google.genai import Client
|
from google.genai import Client
|
||||||
from google.genai.errors import APIError, ClientError
|
from google.genai.errors import APIError, ClientError
|
||||||
@ -31,7 +31,7 @@ import voluptuous as vol
|
|||||||
from voluptuous_openapi import convert
|
from voluptuous_openapi import convert
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr, llm
|
from homeassistant.helpers import device_registry as dr, llm
|
||||||
@ -60,6 +60,9 @@ from .const import (
|
|||||||
TIMEOUT_MILLIS,
|
TIMEOUT_MILLIS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from . import GoogleGenerativeAIConfigEntry
|
||||||
|
|
||||||
# Max number of back and forth with the LLM to generate a response
|
# Max number of back and forth with the LLM to generate a response
|
||||||
MAX_TOOL_ITERATIONS = 10
|
MAX_TOOL_ITERATIONS = 10
|
||||||
|
|
||||||
@ -313,7 +316,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
entry: ConfigEntry,
|
entry: GoogleGenerativeAIConfigEntry,
|
||||||
subentry: ConfigSubentry,
|
subentry: ConfigSubentry,
|
||||||
default_model: str = RECOMMENDED_CHAT_MODEL,
|
default_model: str = RECOMMENDED_CHAT_MODEL,
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -438,6 +441,14 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
|||||||
user_message = chat_log.content[-1]
|
user_message = chat_log.content[-1]
|
||||||
assert isinstance(user_message, conversation.UserContent)
|
assert isinstance(user_message, conversation.UserContent)
|
||||||
chat_request: str | list[Part] = user_message.content
|
chat_request: str | list[Part] = user_message.content
|
||||||
|
if user_message.attachments:
|
||||||
|
files = await async_prepare_files_for_prompt(
|
||||||
|
self.hass,
|
||||||
|
self._genai_client,
|
||||||
|
[a.path for a in user_message.attachments],
|
||||||
|
)
|
||||||
|
chat_request = [chat_request, *files]
|
||||||
|
|
||||||
# To prevent infinite loops, we limit the number of iterations
|
# To prevent infinite loops, we limit the number of iterations
|
||||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||||
try:
|
try:
|
||||||
@ -508,7 +519,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
|||||||
async def async_prepare_files_for_prompt(
|
async def async_prepare_files_for_prompt(
|
||||||
hass: HomeAssistant, client: Client, files: list[Path]
|
hass: HomeAssistant, client: Client, files: list[Path]
|
||||||
) -> list[File]:
|
) -> list[File]:
|
||||||
"""Append files to a prompt.
|
"""Upload files so they can be attached to a prompt.
|
||||||
|
|
||||||
Caller needs to ensure that the files are allowed.
|
Caller needs to ensure that the files are allowed.
|
||||||
"""
|
"""
|
||||||
|
@ -24,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
|||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers import selector
|
from homeassistant.helpers import selector
|
||||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
||||||
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
from .const import DOMAIN, ENTRY_TITLE
|
from .const import DOMAIN, ENTRY_TITLE
|
||||||
from .coordinator import HeosConfigEntry
|
from .coordinator import HeosConfigEntry
|
||||||
@ -142,51 +143,16 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert discovery_info.ssdp_location
|
assert discovery_info.ssdp_location
|
||||||
|
|
||||||
entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
|
|
||||||
hostname = urlparse(discovery_info.ssdp_location).hostname
|
hostname = urlparse(discovery_info.ssdp_location).hostname
|
||||||
assert hostname is not None
|
assert hostname is not None
|
||||||
|
|
||||||
# Abort early when discovery is ignored or host is part of the current system
|
return await self._async_handle_discovered(hostname)
|
||||||
if entry and (
|
|
||||||
entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry)
|
|
||||||
):
|
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
|
||||||
|
|
||||||
# Connect to discovered host and get system information
|
async def async_step_zeroconf(
|
||||||
heos = Heos(HeosOptions(hostname, events=False, heart_beat=False))
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
try:
|
) -> ConfigFlowResult:
|
||||||
await heos.connect()
|
"""Handle zeroconf discovery."""
|
||||||
system_info = await heos.get_system_info()
|
return await self._async_handle_discovered(discovery_info.host)
|
||||||
except HeosError as error:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Failed to retrieve system information from discovered HEOS device %s",
|
|
||||||
hostname,
|
|
||||||
exc_info=error,
|
|
||||||
)
|
|
||||||
return self.async_abort(reason="cannot_connect")
|
|
||||||
finally:
|
|
||||||
await heos.disconnect()
|
|
||||||
|
|
||||||
# Select the preferred host, if available
|
|
||||||
if system_info.preferred_hosts:
|
|
||||||
hostname = system_info.preferred_hosts[0].ip_address
|
|
||||||
|
|
||||||
# Move to confirmation when not configured
|
|
||||||
if entry is None:
|
|
||||||
self._discovered_host = hostname
|
|
||||||
return await self.async_step_confirm_discovery()
|
|
||||||
|
|
||||||
# Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload
|
|
||||||
if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname
|
|
||||||
)
|
|
||||||
return self.async_update_reload_and_abort(
|
|
||||||
entry,
|
|
||||||
data_updates={CONF_HOST: hostname},
|
|
||||||
reason="reconfigure_successful",
|
|
||||||
)
|
|
||||||
return self.async_abort(reason="single_instance_allowed")
|
|
||||||
|
|
||||||
async def async_step_confirm_discovery(
|
async def async_step_confirm_discovery(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@ -267,6 +233,50 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _async_handle_discovered(self, hostname: str) -> ConfigFlowResult:
|
||||||
|
entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
|
||||||
|
# Abort early when discovery is ignored or host is part of the current system
|
||||||
|
if entry and (
|
||||||
|
entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry)
|
||||||
|
):
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
# Connect to discovered host and get system information
|
||||||
|
heos = Heos(HeosOptions(hostname, events=False, heart_beat=False))
|
||||||
|
try:
|
||||||
|
await heos.connect()
|
||||||
|
system_info = await heos.get_system_info()
|
||||||
|
except HeosError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Failed to retrieve system information from discovered HEOS device %s",
|
||||||
|
hostname,
|
||||||
|
exc_info=error,
|
||||||
|
)
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
finally:
|
||||||
|
await heos.disconnect()
|
||||||
|
|
||||||
|
# Select the preferred host, if available
|
||||||
|
if system_info.preferred_hosts and system_info.preferred_hosts[0].ip_address:
|
||||||
|
hostname = system_info.preferred_hosts[0].ip_address
|
||||||
|
|
||||||
|
# Move to confirmation when not configured
|
||||||
|
if entry is None:
|
||||||
|
self._discovered_host = hostname
|
||||||
|
return await self.async_step_confirm_discovery()
|
||||||
|
|
||||||
|
# Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload
|
||||||
|
if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname
|
||||||
|
)
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
entry,
|
||||||
|
data_updates={CONF_HOST: hostname},
|
||||||
|
reason="reconfigure_successful",
|
||||||
|
)
|
||||||
|
return self.async_abort(reason="single_instance_allowed")
|
||||||
|
|
||||||
|
|
||||||
class HeosOptionsFlowHandler(OptionsFlow):
|
class HeosOptionsFlowHandler(OptionsFlow):
|
||||||
"""Define HEOS options flow."""
|
"""Define HEOS options flow."""
|
||||||
|
@ -13,5 +13,6 @@
|
|||||||
{
|
{
|
||||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"zeroconf": ["_heos-audio._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
|
from homeassistant.const import CONF_ENTITY_ID, CONF_STATE
|
||||||
@ -11,7 +12,10 @@ from homeassistant.helpers.device import (
|
|||||||
async_entity_id_to_device_id,
|
async_entity_id_to_device_id,
|
||||||
async_remove_stale_devices_links_keep_entity_device,
|
async_remove_stale_devices_links_keep_entity_device,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
from homeassistant.helpers.helper_integration import (
|
||||||
|
async_handle_source_entity_changes,
|
||||||
|
async_remove_helper_config_entry_from_source_device,
|
||||||
|
)
|
||||||
from homeassistant.helpers.template import Template
|
from homeassistant.helpers.template import Template
|
||||||
|
|
||||||
from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS
|
from .const import CONF_DURATION, CONF_END, CONF_START, PLATFORMS
|
||||||
@ -20,6 +24,8 @@ from .data import HistoryStats
|
|||||||
|
|
||||||
type HistoryStatsConfigEntry = ConfigEntry[HistoryStatsUpdateCoordinator]
|
type HistoryStatsConfigEntry = ConfigEntry[HistoryStatsUpdateCoordinator]
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: HistoryStatsConfigEntry
|
hass: HomeAssistant, entry: HistoryStatsConfigEntry
|
||||||
@ -47,6 +53,7 @@ async def async_setup_entry(
|
|||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
entry.runtime_data = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
|
# This can be removed in HA Core 2026.2
|
||||||
async_remove_stale_devices_links_keep_entity_device(
|
async_remove_stale_devices_links_keep_entity_device(
|
||||||
hass,
|
hass,
|
||||||
entry.entry_id,
|
entry.entry_id,
|
||||||
@ -67,6 +74,7 @@ async def async_setup_entry(
|
|||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
async_handle_source_entity_changes(
|
async_handle_source_entity_changes(
|
||||||
hass,
|
hass,
|
||||||
|
add_helper_config_entry_to_device=False,
|
||||||
helper_config_entry_id=entry.entry_id,
|
helper_config_entry_id=entry.entry_id,
|
||||||
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
||||||
source_device_id=async_entity_id_to_device_id(
|
source_device_id=async_entity_id_to_device_id(
|
||||||
@ -83,6 +91,40 @@ async def async_setup_entry(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_entry.version > 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
return False
|
||||||
|
if config_entry.version == 1:
|
||||||
|
options = {**config_entry.options}
|
||||||
|
if config_entry.minor_version < 2:
|
||||||
|
# Remove the history_stats config entry from the source device
|
||||||
|
if source_device_id := async_entity_id_to_device_id(
|
||||||
|
hass, options[CONF_ENTITY_ID]
|
||||||
|
):
|
||||||
|
async_remove_helper_config_entry_from_source_device(
|
||||||
|
hass,
|
||||||
|
helper_config_entry_id=config_entry.entry_id,
|
||||||
|
source_device_id=source_device_id,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, minor_version=2
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(
|
async def async_unload_entry(
|
||||||
hass: HomeAssistant, entry: HistoryStatsConfigEntry
|
hass: HomeAssistant, entry: HistoryStatsConfigEntry
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
@ -124,6 +124,8 @@ OPTIONS_FLOW = {
|
|||||||
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
class HistoryStatsConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||||
"""Handle a config flow for History stats."""
|
"""Handle a config flow for History stats."""
|
||||||
|
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
config_flow = CONFIG_FLOW
|
config_flow = CONFIG_FLOW
|
||||||
options_flow = OPTIONS_FLOW
|
options_flow = OPTIONS_FLOW
|
||||||
|
|
||||||
@ -229,7 +231,12 @@ async def ws_start_preview(
|
|||||||
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True)
|
coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, None, name, True)
|
||||||
await coordinator.async_refresh()
|
await coordinator.async_refresh()
|
||||||
preview_entity = HistoryStatsSensor(
|
preview_entity = HistoryStatsSensor(
|
||||||
hass, coordinator, sensor_type, name, None, entity_id
|
hass,
|
||||||
|
coordinator=coordinator,
|
||||||
|
sensor_type=sensor_type,
|
||||||
|
name=name,
|
||||||
|
unique_id=None,
|
||||||
|
source_entity_id=entity_id,
|
||||||
)
|
)
|
||||||
preview_entity.hass = hass
|
preview_entity.hass = hass
|
||||||
|
|
||||||
|
@ -27,7 +27,7 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import PlatformNotReady
|
from homeassistant.exceptions import PlatformNotReady
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
from homeassistant.helpers.device import async_entity_id_to_device
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import (
|
||||||
AddConfigEntryEntitiesCallback,
|
AddConfigEntryEntitiesCallback,
|
||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
@ -113,7 +113,16 @@ async def async_setup_platform(
|
|||||||
if not coordinator.last_update_success:
|
if not coordinator.last_update_success:
|
||||||
raise PlatformNotReady from coordinator.last_exception
|
raise PlatformNotReady from coordinator.last_exception
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[HistoryStatsSensor(hass, coordinator, sensor_type, name, unique_id, entity_id)]
|
[
|
||||||
|
HistoryStatsSensor(
|
||||||
|
hass,
|
||||||
|
coordinator=coordinator,
|
||||||
|
sensor_type=sensor_type,
|
||||||
|
name=name,
|
||||||
|
unique_id=unique_id,
|
||||||
|
source_entity_id=entity_id,
|
||||||
|
)
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -130,7 +139,12 @@ async def async_setup_entry(
|
|||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
HistoryStatsSensor(
|
HistoryStatsSensor(
|
||||||
hass, coordinator, sensor_type, entry.title, entry.entry_id, entity_id
|
hass,
|
||||||
|
coordinator=coordinator,
|
||||||
|
sensor_type=sensor_type,
|
||||||
|
name=entry.title,
|
||||||
|
unique_id=entry.entry_id,
|
||||||
|
source_entity_id=entity_id,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@ -176,6 +190,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
*,
|
||||||
coordinator: HistoryStatsUpdateCoordinator,
|
coordinator: HistoryStatsUpdateCoordinator,
|
||||||
sensor_type: str,
|
sensor_type: str,
|
||||||
name: str,
|
name: str,
|
||||||
@ -190,10 +205,11 @@ class HistoryStatsSensor(HistoryStatsSensorBase):
|
|||||||
self._attr_native_unit_of_measurement = UNITS[sensor_type]
|
self._attr_native_unit_of_measurement = UNITS[sensor_type]
|
||||||
self._type = sensor_type
|
self._type = sensor_type
|
||||||
self._attr_unique_id = unique_id
|
self._attr_unique_id = unique_id
|
||||||
self._attr_device_info = async_device_info_to_link_from_entity(
|
if source_entity_id: # Guard against empty source_entity_id in preview mode
|
||||||
hass,
|
self.device_entry = async_entity_id_to_device(
|
||||||
source_entity_id,
|
hass,
|
||||||
)
|
source_entity_id,
|
||||||
|
)
|
||||||
self._process_update()
|
self._process_update()
|
||||||
if self._type == CONF_TYPE_TIME:
|
if self._type == CONF_TYPE_TIME:
|
||||||
self._attr_device_class = SensorDeviceClass.DURATION
|
self._attr_device_class = SensorDeviceClass.DURATION
|
||||||
|
@ -38,7 +38,7 @@ from propcache.api import cached_property
|
|||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -626,39 +626,37 @@ class HomeConnectCoordinator(
|
|||||||
"""Check if the appliance data hasn't been refreshed too often recently."""
|
"""Check if the appliance data hasn't been refreshed too often recently."""
|
||||||
|
|
||||||
now = self.hass.loop.time()
|
now = self.hass.loop.time()
|
||||||
if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS:
|
|
||||||
return True
|
execution_tracker = self._execution_tracker[appliance_ha_id]
|
||||||
|
initial_len = len(execution_tracker)
|
||||||
|
|
||||||
execution_tracker = self._execution_tracker[appliance_ha_id] = [
|
execution_tracker = self._execution_tracker[appliance_ha_id] = [
|
||||||
timestamp
|
timestamp
|
||||||
for timestamp in self._execution_tracker[appliance_ha_id]
|
for timestamp in execution_tracker
|
||||||
if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
|
if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW
|
||||||
]
|
]
|
||||||
|
|
||||||
execution_tracker.append(now)
|
execution_tracker.append(now)
|
||||||
|
|
||||||
if len(execution_tracker) >= MAX_EXECUTIONS:
|
if len(execution_tracker) >= MAX_EXECUTIONS:
|
||||||
ir.async_create_issue(
|
if initial_len < MAX_EXECUTIONS:
|
||||||
self.hass,
|
_LOGGER.warning(
|
||||||
DOMAIN,
|
'Too many connected/paired events for appliance "%s" '
|
||||||
f"home_connect_too_many_connected_paired_events_{appliance_ha_id}",
|
"(%s times in less than %s minutes), updates have been disabled "
|
||||||
is_fixable=True,
|
"and they will be enabled again whenever the connection stabilizes. "
|
||||||
is_persistent=True,
|
"Consider trying to unplug the appliance "
|
||||||
severity=ir.IssueSeverity.ERROR,
|
"for a while to perform a soft reset",
|
||||||
translation_key="home_connect_too_many_connected_paired_events",
|
self.data[appliance_ha_id].info.name,
|
||||||
data={
|
MAX_EXECUTIONS,
|
||||||
"entry_id": self.config_entry.entry_id,
|
MAX_EXECUTIONS_TIME_WINDOW // 60,
|
||||||
"appliance_ha_id": appliance_ha_id,
|
)
|
||||||
},
|
|
||||||
translation_placeholders={
|
|
||||||
"appliance_name": self.data[appliance_ha_id].info.name,
|
|
||||||
"times": str(MAX_EXECUTIONS),
|
|
||||||
"time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60),
|
|
||||||
"home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/",
|
|
||||||
"home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return True
|
return True
|
||||||
|
if initial_len >= MAX_EXECUTIONS:
|
||||||
|
_LOGGER.info(
|
||||||
|
'Connected/paired events from the appliance "%s" have stabilized,'
|
||||||
|
" updates have been re-enabled",
|
||||||
|
self.data[appliance_ha_id].info.name,
|
||||||
|
)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
@ -124,17 +124,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
"home_connect_too_many_connected_paired_events": {
|
|
||||||
"title": "{appliance_name} sent too many connected or paired events",
|
|
||||||
"fix_flow": {
|
|
||||||
"step": {
|
|
||||||
"confirm": {
|
|
||||||
"title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]",
|
|
||||||
"description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"deprecated_time_alarm_clock_in_automations_scripts": {
|
"deprecated_time_alarm_clock_in_automations_scripts": {
|
||||||
"title": "Deprecated alarm clock entity detected in some automations or scripts",
|
"title": "Deprecated alarm clock entity detected in some automations or scripts",
|
||||||
"fix_flow": {
|
"fix_flow": {
|
||||||
|
@ -113,9 +113,7 @@ class HomematicipHAP:
|
|||||||
|
|
||||||
self._ws_close_requested = False
|
self._ws_close_requested = False
|
||||||
self._ws_connection_closed = asyncio.Event()
|
self._ws_connection_closed = asyncio.Event()
|
||||||
self._retry_task: asyncio.Task | None = None
|
self._get_state_task: asyncio.Task | None = None
|
||||||
self._tries = 0
|
|
||||||
self._accesspoint_connected = True
|
|
||||||
self.hmip_device_by_entity_id: dict[str, Any] = {}
|
self.hmip_device_by_entity_id: dict[str, Any] = {}
|
||||||
self.reset_connection_listener: Callable | None = None
|
self.reset_connection_listener: Callable | None = None
|
||||||
|
|
||||||
@ -161,17 +159,8 @@ class HomematicipHAP:
|
|||||||
"""
|
"""
|
||||||
if not self.home.connected:
|
if not self.home.connected:
|
||||||
_LOGGER.error("HMIP access point has lost connection with the cloud")
|
_LOGGER.error("HMIP access point has lost connection with the cloud")
|
||||||
self._accesspoint_connected = False
|
self._ws_connection_closed.set()
|
||||||
self.set_all_to_unavailable()
|
self.set_all_to_unavailable()
|
||||||
elif not self._accesspoint_connected:
|
|
||||||
# Now the HOME_CHANGED event has fired indicating the access
|
|
||||||
# point has reconnected to the cloud again.
|
|
||||||
# Explicitly getting an update as entity states might have
|
|
||||||
# changed during access point disconnect."""
|
|
||||||
|
|
||||||
job = self.hass.async_create_task(self.get_state())
|
|
||||||
job.add_done_callback(self.get_state_finished)
|
|
||||||
self._accesspoint_connected = True
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_create_entity(self, *args, **kwargs) -> None:
|
def async_create_entity(self, *args, **kwargs) -> None:
|
||||||
@ -185,20 +174,43 @@ class HomematicipHAP:
|
|||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||||
|
|
||||||
|
async def _try_get_state(self) -> None:
|
||||||
|
"""Call get_state in a loop until no error occurs, using exponential backoff on error."""
|
||||||
|
|
||||||
|
# Wait until WebSocket connection is established.
|
||||||
|
while not self.home.websocket_is_connected():
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
|
||||||
|
delay = 8
|
||||||
|
max_delay = 1500
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await self.get_state()
|
||||||
|
break
|
||||||
|
except HmipConnectionError as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Get_state failed, retrying in %s seconds: %s", delay, err
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
delay = min(delay * 2, max_delay)
|
||||||
|
|
||||||
async def get_state(self) -> None:
|
async def get_state(self) -> None:
|
||||||
"""Update HMIP state and tell Home Assistant."""
|
"""Update HMIP state and tell Home Assistant."""
|
||||||
await self.home.get_current_state_async()
|
await self.home.get_current_state_async()
|
||||||
self.update_all()
|
self.update_all()
|
||||||
|
|
||||||
def get_state_finished(self, future) -> None:
|
def get_state_finished(self, future) -> None:
|
||||||
"""Execute when get_state coroutine has finished."""
|
"""Execute when try_get_state coroutine has finished."""
|
||||||
try:
|
try:
|
||||||
future.result()
|
future.result()
|
||||||
except HmipConnectionError:
|
except Exception as err: # noqa: BLE001
|
||||||
# Somehow connection could not recover. Will disconnect and
|
_LOGGER.error(
|
||||||
# so reconnect loop is taking over.
|
"Error updating state after HMIP access point reconnect: %s", err
|
||||||
_LOGGER.error("Updating state after HMIP access point reconnect failed")
|
)
|
||||||
self.hass.async_create_task(self.home.disable_events())
|
else:
|
||||||
|
_LOGGER.info(
|
||||||
|
"Updating state after HMIP access point reconnect finished successfully",
|
||||||
|
)
|
||||||
|
|
||||||
def set_all_to_unavailable(self) -> None:
|
def set_all_to_unavailable(self) -> None:
|
||||||
"""Set all devices to unavailable and tell Home Assistant."""
|
"""Set all devices to unavailable and tell Home Assistant."""
|
||||||
@ -222,8 +234,8 @@ class HomematicipHAP:
|
|||||||
async def async_reset(self) -> bool:
|
async def async_reset(self) -> bool:
|
||||||
"""Close the websocket connection."""
|
"""Close the websocket connection."""
|
||||||
self._ws_close_requested = True
|
self._ws_close_requested = True
|
||||||
if self._retry_task is not None:
|
if self._get_state_task is not None:
|
||||||
self._retry_task.cancel()
|
self._get_state_task.cancel()
|
||||||
await self.home.disable_events_async()
|
await self.home.disable_events_async()
|
||||||
_LOGGER.debug("Closed connection to HomematicIP cloud server")
|
_LOGGER.debug("Closed connection to HomematicIP cloud server")
|
||||||
await self.hass.config_entries.async_unload_platforms(
|
await self.hass.config_entries.async_unload_platforms(
|
||||||
@ -247,7 +259,9 @@ class HomematicipHAP:
|
|||||||
"""Handle websocket connected."""
|
"""Handle websocket connected."""
|
||||||
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
|
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
|
||||||
if self._ws_connection_closed.is_set():
|
if self._ws_connection_closed.is_set():
|
||||||
await self.get_state()
|
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||||
|
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||||
|
|
||||||
self._ws_connection_closed.clear()
|
self._ws_connection_closed.clear()
|
||||||
|
|
||||||
async def ws_disconnected_handler(self) -> None:
|
async def ws_disconnected_handler(self) -> None:
|
||||||
@ -256,11 +270,12 @@ class HomematicipHAP:
|
|||||||
self._ws_connection_closed.set()
|
self._ws_connection_closed.set()
|
||||||
|
|
||||||
async def ws_reconnected_handler(self, reason: str) -> None:
|
async def ws_reconnected_handler(self, reason: str) -> None:
|
||||||
"""Handle websocket reconnection."""
|
"""Handle websocket reconnection. Is called when Websocket tries to reconnect."""
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Websocket connection to HomematicIP Cloud re-established due to reason: %s",
|
"Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s",
|
||||||
reason,
|
reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
self._ws_connection_closed.set()
|
self._ws_connection_closed.set()
|
||||||
|
|
||||||
async def get_hap(
|
async def get_hap(
|
||||||
|
@ -2,13 +2,20 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homematicip.base.enums import DeviceType, OpticalSignalBehaviour, RGBColorState
|
from homematicip.base.enums import (
|
||||||
|
DeviceType,
|
||||||
|
FunctionalChannelType,
|
||||||
|
OpticalSignalBehaviour,
|
||||||
|
RGBColorState,
|
||||||
|
)
|
||||||
from homematicip.base.functionalChannels import NotificationLightChannel
|
from homematicip.base.functionalChannels import NotificationLightChannel
|
||||||
from homematicip.device import (
|
from homematicip.device import (
|
||||||
BrandDimmer,
|
BrandDimmer,
|
||||||
BrandSwitchNotificationLight,
|
BrandSwitchNotificationLight,
|
||||||
|
Device,
|
||||||
Dimmer,
|
Dimmer,
|
||||||
DinRailDimmer3,
|
DinRailDimmer3,
|
||||||
FullFlushDimmer,
|
FullFlushDimmer,
|
||||||
@ -34,6 +41,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|||||||
from .entity import HomematicipGenericEntity
|
from .entity import HomematicipGenericEntity
|
||||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -43,6 +52,14 @@ async def async_setup_entry(
|
|||||||
"""Set up the HomematicIP Cloud lights from a config entry."""
|
"""Set up the HomematicIP Cloud lights from a config entry."""
|
||||||
hap = config_entry.runtime_data
|
hap = config_entry.runtime_data
|
||||||
entities: list[HomematicipGenericEntity] = []
|
entities: list[HomematicipGenericEntity] = []
|
||||||
|
|
||||||
|
entities.extend(
|
||||||
|
HomematicipLightHS(hap, d, ch.index)
|
||||||
|
for d in hap.home.devices
|
||||||
|
for ch in d.functionalChannels
|
||||||
|
if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL
|
||||||
|
)
|
||||||
|
|
||||||
for device in hap.home.devices:
|
for device in hap.home.devices:
|
||||||
if (
|
if (
|
||||||
isinstance(device, SwitchMeasuring)
|
isinstance(device, SwitchMeasuring)
|
||||||
@ -104,6 +121,64 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
|
|||||||
await self._device.turn_off_async()
|
await self._device.turn_off_async()
|
||||||
|
|
||||||
|
|
||||||
|
class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
|
||||||
|
"""Representation of the HomematicIP light with HS color mode."""
|
||||||
|
|
||||||
|
_attr_color_mode = ColorMode.HS
|
||||||
|
_attr_supported_color_modes = {ColorMode.HS}
|
||||||
|
|
||||||
|
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
|
||||||
|
"""Initialize the light entity."""
|
||||||
|
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_on(self) -> bool:
|
||||||
|
"""Return true if light is on."""
|
||||||
|
return self.functional_channel.on
|
||||||
|
|
||||||
|
@property
|
||||||
|
def brightness(self) -> int | None:
|
||||||
|
"""Return the current brightness."""
|
||||||
|
return int(self.functional_channel.dimLevel * 255.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hs_color(self) -> tuple[float, float] | None:
|
||||||
|
"""Return the hue and saturation color value [float, float]."""
|
||||||
|
if (
|
||||||
|
self.functional_channel.hue is None
|
||||||
|
or self.functional_channel.saturationLevel is None
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
self.functional_channel.hue,
|
||||||
|
self.functional_channel.saturationLevel * 100.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn the light on."""
|
||||||
|
|
||||||
|
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
|
||||||
|
hue = hs_color[0] % 360.0
|
||||||
|
saturation = hs_color[1] / 100.0
|
||||||
|
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
|
||||||
|
|
||||||
|
if ATTR_HS_COLOR not in kwargs:
|
||||||
|
hue = self.functional_channel.hue
|
||||||
|
saturation = self.functional_channel.saturationLevel
|
||||||
|
|
||||||
|
if ATTR_BRIGHTNESS not in kwargs:
|
||||||
|
# If no brightness is set, use the current brightness
|
||||||
|
dim_level = self.functional_channel.dimLevel or 1.0
|
||||||
|
|
||||||
|
await self.functional_channel.set_hue_saturation_dim_level_async(
|
||||||
|
hue=hue, saturation_level=saturation, dim_level=dim_level
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
|
"""Turn the light off."""
|
||||||
|
await self.functional_channel.set_switch_state_async(on=False)
|
||||||
|
|
||||||
|
|
||||||
class HomematicipLightMeasuring(HomematicipLight):
|
class HomematicipLightMeasuring(HomematicipLight):
|
||||||
"""Representation of the HomematicIP measuring light."""
|
"""Representation of the HomematicIP measuring light."""
|
||||||
|
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["homematicip"],
|
"loggers": ["homematicip"],
|
||||||
"requirements": ["homematicip==2.0.6"]
|
"requirements": ["homematicip==2.0.7"]
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,9 @@ from homematicip.device import (
|
|||||||
PrintedCircuitBoardSwitch2,
|
PrintedCircuitBoardSwitch2,
|
||||||
PrintedCircuitBoardSwitchBattery,
|
PrintedCircuitBoardSwitchBattery,
|
||||||
SwitchMeasuring,
|
SwitchMeasuring,
|
||||||
|
WiredInput32,
|
||||||
|
WiredInputSwitch6,
|
||||||
|
WiredSwitch4,
|
||||||
WiredSwitch8,
|
WiredSwitch8,
|
||||||
)
|
)
|
||||||
from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup
|
from homematicip.group import ExtendedLinkedSwitchingGroup, SwitchingGroup
|
||||||
@ -51,6 +54,7 @@ async def async_setup_entry(
|
|||||||
elif isinstance(
|
elif isinstance(
|
||||||
device,
|
device,
|
||||||
(
|
(
|
||||||
|
WiredSwitch4,
|
||||||
WiredSwitch8,
|
WiredSwitch8,
|
||||||
OpenCollector8Module,
|
OpenCollector8Module,
|
||||||
BrandSwitch2,
|
BrandSwitch2,
|
||||||
@ -60,6 +64,8 @@ async def async_setup_entry(
|
|||||||
MotionDetectorSwitchOutdoor,
|
MotionDetectorSwitchOutdoor,
|
||||||
DinRailSwitch,
|
DinRailSwitch,
|
||||||
DinRailSwitch4,
|
DinRailSwitch4,
|
||||||
|
WiredInput32,
|
||||||
|
WiredInputSwitch6,
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
channel_indices = [
|
channel_indices = [
|
||||||
|
@ -29,7 +29,7 @@
|
|||||||
"services": {
|
"services": {
|
||||||
"dismiss": {
|
"dismiss": {
|
||||||
"name": "Dismiss",
|
"name": "Dismiss",
|
||||||
"description": "Dismisses a html5 notification.",
|
"description": "Dismisses an HTML5 notification.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"target": {
|
"target": {
|
||||||
"name": "Target",
|
"name": "Target",
|
||||||
|
@ -64,7 +64,7 @@ def setup_bans(hass: HomeAssistant, app: Application, login_threshold: int) -> N
|
|||||||
"""Initialize bans when app starts up."""
|
"""Initialize bans when app starts up."""
|
||||||
await app[KEY_BAN_MANAGER].async_load()
|
await app[KEY_BAN_MANAGER].async_load()
|
||||||
|
|
||||||
app.on_startup.append(ban_startup) # type: ignore[arg-type]
|
app.on_startup.append(ban_startup)
|
||||||
|
|
||||||
|
|
||||||
@middleware
|
@middleware
|
||||||
|
@ -11,9 +11,9 @@ from aiopvapi.shades import Shades
|
|||||||
from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform
|
from homeassistant.const import CONF_API_VERSION, CONF_HOST, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import entity_registry as er
|
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||||
|
|
||||||
from .const import DOMAIN, HUB_EXCEPTIONS
|
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
|
||||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||||
from .model import PowerviewConfigEntry, PowerviewEntryData
|
from .model import PowerviewConfigEntry, PowerviewEntryData
|
||||||
from .shade_data import PowerviewShadeData
|
from .shade_data import PowerviewShadeData
|
||||||
@ -64,6 +64,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: PowerviewConfigEntry) ->
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# manual registration of the hub
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
device_registry.async_get_or_create(
|
||||||
|
config_entry_id=entry.entry_id,
|
||||||
|
connections={(dr.CONNECTION_NETWORK_MAC, hub.mac_address)},
|
||||||
|
identifiers={(DOMAIN, hub.serial_number)},
|
||||||
|
manufacturer=MANUFACTURER,
|
||||||
|
name=hub.name,
|
||||||
|
model=hub.model,
|
||||||
|
sw_version=hub.firmware,
|
||||||
|
hw_version=hub.main_processor_version.name,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
rooms = Rooms(pv_request)
|
rooms = Rooms(pv_request)
|
||||||
room_data: PowerviewData = await rooms.get_rooms()
|
room_data: PowerviewData = await rooms.get_rooms()
|
||||||
|
@ -60,15 +60,7 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
self._devices_last_update: set[str] = set()
|
self._devices_last_update: set[str] = set()
|
||||||
self._zones_last_update: dict[str, set[str]] = {}
|
self._zones_last_update: dict[str, set[str]] = {}
|
||||||
self._areas_last_update: dict[str, set[int]] = {}
|
self._areas_last_update: dict[str, set[int]] = {}
|
||||||
|
self.async_add_listener(self._on_data_update)
|
||||||
def _async_add_remove_devices_and_entities(self, data: MowerDictionary) -> None:
|
|
||||||
"""Add/remove devices and dynamic entities, when amount of devices changed."""
|
|
||||||
self._async_add_remove_devices(data)
|
|
||||||
for mower_id in data:
|
|
||||||
if data[mower_id].capabilities.stay_out_zones:
|
|
||||||
self._async_add_remove_stay_out_zones(data)
|
|
||||||
if data[mower_id].capabilities.work_areas:
|
|
||||||
self._async_add_remove_work_areas(data)
|
|
||||||
|
|
||||||
async def _async_update_data(self) -> MowerDictionary:
|
async def _async_update_data(self) -> MowerDictionary:
|
||||||
"""Subscribe for websocket and poll data from the API."""
|
"""Subscribe for websocket and poll data from the API."""
|
||||||
@ -82,14 +74,38 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
raise UpdateFailed(err) from err
|
raise UpdateFailed(err) from err
|
||||||
except AuthError as err:
|
except AuthError as err:
|
||||||
raise ConfigEntryAuthFailed(err) from err
|
raise ConfigEntryAuthFailed(err) from err
|
||||||
self._async_add_remove_devices_and_entities(data)
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _on_data_update(self) -> None:
|
||||||
|
"""Handle data updates and process dynamic entity management."""
|
||||||
|
if self.data is not None:
|
||||||
|
self._async_add_remove_devices()
|
||||||
|
for mower_id in self.data:
|
||||||
|
if self.data[mower_id].capabilities.stay_out_zones:
|
||||||
|
self._async_add_remove_stay_out_zones()
|
||||||
|
if self.data[mower_id].capabilities.work_areas:
|
||||||
|
self._async_add_remove_work_areas()
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
|
def handle_websocket_updates(self, ws_data: MowerDictionary) -> None:
|
||||||
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
|
"""Process websocket callbacks and write them to the DataUpdateCoordinator."""
|
||||||
|
self.hass.async_create_task(self._process_websocket_update(ws_data))
|
||||||
|
|
||||||
|
async def _process_websocket_update(self, ws_data: MowerDictionary) -> None:
|
||||||
|
"""Handle incoming websocket update and update coordinator data."""
|
||||||
|
for data in ws_data.values():
|
||||||
|
existing_areas = data.work_areas or {}
|
||||||
|
for task in data.calendar.tasks:
|
||||||
|
work_area_id = task.work_area_id
|
||||||
|
if work_area_id is not None and work_area_id not in existing_areas:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"New work area %s detected, refreshing data", work_area_id
|
||||||
|
)
|
||||||
|
await self.async_request_refresh()
|
||||||
|
return
|
||||||
|
|
||||||
self.async_set_updated_data(ws_data)
|
self.async_set_updated_data(ws_data)
|
||||||
self._async_add_remove_devices_and_entities(ws_data)
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_set_updated_data(self, data: MowerDictionary) -> None:
|
def async_set_updated_data(self, data: MowerDictionary) -> None:
|
||||||
@ -138,9 +154,9 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
"reconnect_task",
|
"reconnect_task",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _async_add_remove_devices(self, data: MowerDictionary) -> None:
|
def _async_add_remove_devices(self) -> None:
|
||||||
"""Add new device, remove non-existing device."""
|
"""Add new device, remove non-existing device."""
|
||||||
current_devices = set(data)
|
current_devices = set(self.data)
|
||||||
|
|
||||||
# Skip update if no changes
|
# Skip update if no changes
|
||||||
if current_devices == self._devices_last_update:
|
if current_devices == self._devices_last_update:
|
||||||
@ -155,7 +171,6 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
# Process new device
|
# Process new device
|
||||||
new_devices = current_devices - self._devices_last_update
|
new_devices = current_devices - self._devices_last_update
|
||||||
if new_devices:
|
if new_devices:
|
||||||
self.data = data
|
|
||||||
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
|
_LOGGER.debug("New devices found: %s", ", ".join(map(str, new_devices)))
|
||||||
self._add_new_devices(new_devices)
|
self._add_new_devices(new_devices)
|
||||||
|
|
||||||
@ -179,11 +194,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
for mower_callback in self.new_devices_callbacks:
|
for mower_callback in self.new_devices_callbacks:
|
||||||
mower_callback(new_devices)
|
mower_callback(new_devices)
|
||||||
|
|
||||||
def _async_add_remove_stay_out_zones(self, data: MowerDictionary) -> None:
|
def _async_add_remove_stay_out_zones(self) -> None:
|
||||||
"""Add new stay-out zones, remove non-existing stay-out zones."""
|
"""Add new stay-out zones, remove non-existing stay-out zones."""
|
||||||
current_zones = {
|
current_zones = {
|
||||||
mower_id: set(mower_data.stay_out_zones.zones)
|
mower_id: set(mower_data.stay_out_zones.zones)
|
||||||
for mower_id, mower_data in data.items()
|
for mower_id, mower_data in self.data.items()
|
||||||
if mower_data.capabilities.stay_out_zones
|
if mower_data.capabilities.stay_out_zones
|
||||||
and mower_data.stay_out_zones is not None
|
and mower_data.stay_out_zones is not None
|
||||||
}
|
}
|
||||||
@ -225,11 +240,11 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[MowerDictionary]):
|
|||||||
|
|
||||||
return current_zones
|
return current_zones
|
||||||
|
|
||||||
def _async_add_remove_work_areas(self, data: MowerDictionary) -> None:
|
def _async_add_remove_work_areas(self) -> None:
|
||||||
"""Add new work areas, remove non-existing work areas."""
|
"""Add new work areas, remove non-existing work areas."""
|
||||||
current_areas = {
|
current_areas = {
|
||||||
mower_id: set(mower_data.work_areas)
|
mower_id: set(mower_data.work_areas)
|
||||||
for mower_id, mower_data in data.items()
|
for mower_id, mower_data in self.data.items()
|
||||||
if mower_data.capabilities.work_areas and mower_data.work_areas is not None
|
if mower_data.capabilities.work_areas and mower_data.work_areas is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,16 +112,8 @@ class HuumDevice(ClimateEntity):
|
|||||||
await self._turn_on(temperature)
|
await self._turn_on(temperature)
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
async def async_update(self) -> None:
|
||||||
"""Get the latest status data.
|
"""Get the latest status data."""
|
||||||
|
self._status = await self._huum_handler.status()
|
||||||
We get the latest status first from the status endpoints of the sauna.
|
|
||||||
If that data does not include the temperature, that means that the sauna
|
|
||||||
is off, we then call the off command which will in turn return the temperature.
|
|
||||||
This is a workaround for getting the temperature as the Huum API does not
|
|
||||||
return the target temperature of a sauna that is off, even if it can have
|
|
||||||
a target temperature at that time.
|
|
||||||
"""
|
|
||||||
self._status = await self._huum_handler.status_from_status_or_stop()
|
|
||||||
if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT:
|
if self._target_temperature is None or self.hvac_mode == HVACMode.HEAT:
|
||||||
self._target_temperature = self._status.target_temperature
|
self._target_temperature = self._status.target_temperature
|
||||||
|
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/huum",
|
"documentation": "https://www.home-assistant.io/integrations/huum",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"requirements": ["huum==0.7.12"]
|
"requirements": ["huum==0.8.0"]
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
@ -9,14 +11,20 @@ from homeassistant.helpers.device import (
|
|||||||
async_entity_id_to_device_id,
|
async_entity_id_to_device_id,
|
||||||
async_remove_stale_devices_links_keep_entity_device,
|
async_remove_stale_devices_links_keep_entity_device,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.helper_integration import async_handle_source_entity_changes
|
from homeassistant.helpers.helper_integration import (
|
||||||
|
async_handle_source_entity_changes,
|
||||||
|
async_remove_helper_config_entry_from_source_device,
|
||||||
|
)
|
||||||
|
|
||||||
from .const import CONF_SOURCE_SENSOR
|
from .const import CONF_SOURCE_SENSOR
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Integration from a config entry."""
|
"""Set up Integration from a config entry."""
|
||||||
|
|
||||||
|
# This can be removed in HA Core 2026.2
|
||||||
async_remove_stale_devices_links_keep_entity_device(
|
async_remove_stale_devices_links_keep_entity_device(
|
||||||
hass,
|
hass,
|
||||||
entry.entry_id,
|
entry.entry_id,
|
||||||
@ -29,20 +37,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id},
|
options={**entry.options, CONF_SOURCE_SENSOR: source_entity_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
async def source_entity_removed() -> None:
|
|
||||||
# The source entity has been removed, we need to clean the device links.
|
|
||||||
async_remove_stale_devices_links_keep_entity_device(hass, entry.entry_id, None)
|
|
||||||
|
|
||||||
entry.async_on_unload(
|
entry.async_on_unload(
|
||||||
async_handle_source_entity_changes(
|
async_handle_source_entity_changes(
|
||||||
hass,
|
hass,
|
||||||
|
add_helper_config_entry_to_device=False,
|
||||||
helper_config_entry_id=entry.entry_id,
|
helper_config_entry_id=entry.entry_id,
|
||||||
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
set_source_entity_id_or_uuid=set_source_entity_id_or_uuid,
|
||||||
source_device_id=async_entity_id_to_device_id(
|
source_device_id=async_entity_id_to_device_id(
|
||||||
hass, entry.options[CONF_SOURCE_SENSOR]
|
hass, entry.options[CONF_SOURCE_SENSOR]
|
||||||
),
|
),
|
||||||
source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR],
|
source_entity_id_or_uuid=entry.options[CONF_SOURCE_SENSOR],
|
||||||
source_entity_removed=source_entity_removed,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -51,6 +55,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||||
|
"""Migrate old entry."""
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migrating from version %s.%s", config_entry.version, config_entry.minor_version
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_entry.version > 1:
|
||||||
|
# This means the user has downgraded from a future version
|
||||||
|
return False
|
||||||
|
if config_entry.version == 1:
|
||||||
|
options = {**config_entry.options}
|
||||||
|
if config_entry.minor_version < 2:
|
||||||
|
# Remove the integration config entry from the source device
|
||||||
|
if source_device_id := async_entity_id_to_device_id(
|
||||||
|
hass, options[CONF_SOURCE_SENSOR]
|
||||||
|
):
|
||||||
|
async_remove_helper_config_entry_from_source_device(
|
||||||
|
hass,
|
||||||
|
helper_config_entry_id=config_entry.entry_id,
|
||||||
|
source_device_id=source_device_id,
|
||||||
|
)
|
||||||
|
hass.config_entries.async_update_entry(
|
||||||
|
config_entry, options=options, minor_version=2
|
||||||
|
)
|
||||||
|
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Migration to version %s.%s successful",
|
||||||
|
config_entry.version,
|
||||||
|
config_entry.minor_version,
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||||
"""Update listener, called when the config entry options are changed."""
|
"""Update listener, called when the config entry options are changed."""
|
||||||
# Remove device link for entry, the source device may have changed.
|
# Remove device link for entry, the source device may have changed.
|
||||||
|
@ -147,6 +147,8 @@ OPTIONS_FLOW = {
|
|||||||
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||||
"""Handle a config or options flow for Integration."""
|
"""Handle a config or options flow for Integration."""
|
||||||
|
|
||||||
|
MINOR_VERSION = 2
|
||||||
|
|
||||||
config_flow = CONFIG_FLOW
|
config_flow = CONFIG_FLOW
|
||||||
options_flow = OPTIONS_FLOW
|
options_flow = OPTIONS_FLOW
|
||||||
|
|
||||||
|
@ -40,8 +40,7 @@ from homeassistant.core import (
|
|||||||
callback,
|
callback,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||||
from homeassistant.helpers.device import async_device_info_to_link_from_entity
|
from homeassistant.helpers.device import async_entity_id_to_device
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
|
||||||
from homeassistant.helpers.entity_platform import (
|
from homeassistant.helpers.entity_platform import (
|
||||||
AddConfigEntryEntitiesCallback,
|
AddConfigEntryEntitiesCallback,
|
||||||
AddEntitiesCallback,
|
AddEntitiesCallback,
|
||||||
@ -246,11 +245,6 @@ async def async_setup_entry(
|
|||||||
registry, config_entry.options[CONF_SOURCE_SENSOR]
|
registry, config_entry.options[CONF_SOURCE_SENSOR]
|
||||||
)
|
)
|
||||||
|
|
||||||
device_info = async_device_info_to_link_from_entity(
|
|
||||||
hass,
|
|
||||||
source_entity_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none":
|
if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none":
|
||||||
# Before we had support for optional selectors, "none" was used for selecting nothing
|
# Before we had support for optional selectors, "none" was used for selecting nothing
|
||||||
unit_prefix = None
|
unit_prefix = None
|
||||||
@ -265,6 +259,7 @@ async def async_setup_entry(
|
|||||||
round_digits = int(round_digits)
|
round_digits = int(round_digits)
|
||||||
|
|
||||||
integral = IntegrationSensor(
|
integral = IntegrationSensor(
|
||||||
|
hass,
|
||||||
integration_method=config_entry.options[CONF_METHOD],
|
integration_method=config_entry.options[CONF_METHOD],
|
||||||
name=config_entry.title,
|
name=config_entry.title,
|
||||||
round_digits=round_digits,
|
round_digits=round_digits,
|
||||||
@ -272,7 +267,6 @@ async def async_setup_entry(
|
|||||||
unique_id=config_entry.entry_id,
|
unique_id=config_entry.entry_id,
|
||||||
unit_prefix=unit_prefix,
|
unit_prefix=unit_prefix,
|
||||||
unit_time=config_entry.options[CONF_UNIT_TIME],
|
unit_time=config_entry.options[CONF_UNIT_TIME],
|
||||||
device_info=device_info,
|
|
||||||
max_sub_interval=max_sub_interval,
|
max_sub_interval=max_sub_interval,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -287,6 +281,7 @@ async def async_setup_platform(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the integration sensor."""
|
"""Set up the integration sensor."""
|
||||||
integral = IntegrationSensor(
|
integral = IntegrationSensor(
|
||||||
|
hass,
|
||||||
integration_method=config[CONF_METHOD],
|
integration_method=config[CONF_METHOD],
|
||||||
name=config.get(CONF_NAME),
|
name=config.get(CONF_NAME),
|
||||||
round_digits=config.get(CONF_ROUND_DIGITS),
|
round_digits=config.get(CONF_ROUND_DIGITS),
|
||||||
@ -308,6 +303,7 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
hass: HomeAssistant,
|
||||||
*,
|
*,
|
||||||
integration_method: str,
|
integration_method: str,
|
||||||
name: str | None,
|
name: str | None,
|
||||||
@ -317,7 +313,6 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
unit_prefix: str | None,
|
unit_prefix: str | None,
|
||||||
unit_time: UnitOfTime,
|
unit_time: UnitOfTime,
|
||||||
max_sub_interval: timedelta | None,
|
max_sub_interval: timedelta | None,
|
||||||
device_info: DeviceInfo | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the integration sensor."""
|
"""Initialize the integration sensor."""
|
||||||
self._attr_unique_id = unique_id
|
self._attr_unique_id = unique_id
|
||||||
@ -335,7 +330,10 @@ class IntegrationSensor(RestoreSensor):
|
|||||||
self._attr_icon = "mdi:chart-histogram"
|
self._attr_icon = "mdi:chart-histogram"
|
||||||
self._source_entity: str = source_entity
|
self._source_entity: str = source_entity
|
||||||
self._last_valid_state: Decimal | None = None
|
self._last_valid_state: Decimal | None = None
|
||||||
self._attr_device_info = device_info
|
self.device_entry = async_entity_id_to_device(
|
||||||
|
hass,
|
||||||
|
source_entity,
|
||||||
|
)
|
||||||
self._max_sub_interval: timedelta | None = (
|
self._max_sub_interval: timedelta | None = (
|
||||||
None # disable time based integration
|
None # disable time based integration
|
||||||
if max_sub_interval is None or max_sub_interval.total_seconds() == 0
|
if max_sub_interval is None or max_sub_interval.total_seconds() == 0
|
||||||
|
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
@ -21,6 +22,8 @@ from .const import CONTENT_TYPE_MAP, LOGGER, MAX_IMAGE_WIDTH
|
|||||||
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
|
from .coordinator import JellyfinConfigEntry, JellyfinDataUpdateCoordinator
|
||||||
from .entity import JellyfinClientEntity
|
from .entity import JellyfinClientEntity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@ -177,10 +180,15 @@ class JellyfinMediaPlayer(JellyfinClientEntity, MediaPlayerEntity):
|
|||||||
def supported_features(self) -> MediaPlayerEntityFeature:
|
def supported_features(self) -> MediaPlayerEntityFeature:
|
||||||
"""Flag media player features that are supported."""
|
"""Flag media player features that are supported."""
|
||||||
commands: list[str] = self.capabilities.get("SupportedCommands", [])
|
commands: list[str] = self.capabilities.get("SupportedCommands", [])
|
||||||
controllable = self.capabilities.get("SupportsMediaControl", False)
|
_LOGGER.debug(
|
||||||
|
"Supported commands for device %s, client %s, %s",
|
||||||
|
self.device_name,
|
||||||
|
self.client_name,
|
||||||
|
commands,
|
||||||
|
)
|
||||||
features = MediaPlayerEntityFeature(0)
|
features = MediaPlayerEntityFeature(0)
|
||||||
|
|
||||||
if controllable:
|
if "PlayMediaSource" in commands:
|
||||||
features |= (
|
features |= (
|
||||||
MediaPlayerEntityFeature.BROWSE_MEDIA
|
MediaPlayerEntityFeature.BROWSE_MEDIA
|
||||||
| MediaPlayerEntityFeature.PLAY_MEDIA
|
| MediaPlayerEntityFeature.PLAY_MEDIA
|
||||||
|
@ -13,8 +13,7 @@ from homeassistant.components.binary_sensor import (
|
|||||||
BinarySensorEntityDescription,
|
BinarySensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import event
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
@ -23,36 +22,29 @@ from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
|
|||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class JewishCalendarBinarySensorMixIns(BinarySensorEntityDescription):
|
class JewishCalendarBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||||
"""Binary Sensor description mixin class for Jewish Calendar."""
|
|
||||||
|
|
||||||
is_on: Callable[[Zmanim, dt.datetime], bool] = lambda _, __: False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class JewishCalendarBinarySensorEntityDescription(
|
|
||||||
JewishCalendarBinarySensorMixIns, BinarySensorEntityDescription
|
|
||||||
):
|
|
||||||
"""Binary Sensor Entity description for Jewish Calendar."""
|
"""Binary Sensor Entity description for Jewish Calendar."""
|
||||||
|
|
||||||
|
is_on: Callable[[Zmanim], Callable[[dt.datetime], bool]]
|
||||||
|
|
||||||
|
|
||||||
BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = (
|
BINARY_SENSORS: tuple[JewishCalendarBinarySensorEntityDescription, ...] = (
|
||||||
JewishCalendarBinarySensorEntityDescription(
|
JewishCalendarBinarySensorEntityDescription(
|
||||||
key="issur_melacha_in_effect",
|
key="issur_melacha_in_effect",
|
||||||
translation_key="issur_melacha_in_effect",
|
translation_key="issur_melacha_in_effect",
|
||||||
is_on=lambda state, now: bool(state.issur_melacha_in_effect(now)),
|
is_on=lambda state: state.issur_melacha_in_effect,
|
||||||
),
|
),
|
||||||
JewishCalendarBinarySensorEntityDescription(
|
JewishCalendarBinarySensorEntityDescription(
|
||||||
key="erev_shabbat_hag",
|
key="erev_shabbat_hag",
|
||||||
translation_key="erev_shabbat_hag",
|
translation_key="erev_shabbat_hag",
|
||||||
is_on=lambda state, now: bool(state.erev_shabbat_chag(now)),
|
is_on=lambda state: state.erev_shabbat_chag,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
JewishCalendarBinarySensorEntityDescription(
|
JewishCalendarBinarySensorEntityDescription(
|
||||||
key="motzei_shabbat_hag",
|
key="motzei_shabbat_hag",
|
||||||
translation_key="motzei_shabbat_hag",
|
translation_key="motzei_shabbat_hag",
|
||||||
is_on=lambda state, now: bool(state.motzei_shabbat_chag(now)),
|
is_on=lambda state: state.motzei_shabbat_chag,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -73,9 +65,7 @@ async def async_setup_entry(
|
|||||||
class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
|
class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
|
||||||
"""Representation of an Jewish Calendar binary sensor."""
|
"""Representation of an Jewish Calendar binary sensor."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
_update_unsub: CALLBACK_TYPE | None = None
|
|
||||||
|
|
||||||
entity_description: JewishCalendarBinarySensorEntityDescription
|
entity_description: JewishCalendarBinarySensorEntityDescription
|
||||||
|
|
||||||
@ -83,40 +73,12 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
|
|||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return true if sensor is on."""
|
"""Return true if sensor is on."""
|
||||||
zmanim = self.make_zmanim(dt.date.today())
|
zmanim = self.make_zmanim(dt.date.today())
|
||||||
return self.entity_description.is_on(zmanim, dt_util.now())
|
return self.entity_description.is_on(zmanim)(dt_util.now())
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||||
"""Run when entity about to be added to hass."""
|
"""Return a list of times to update the sensor."""
|
||||||
await super().async_added_to_hass()
|
return [
|
||||||
self._schedule_update()
|
zmanim.netz_hachama.local + dt.timedelta(days=1),
|
||||||
|
zmanim.candle_lighting,
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
zmanim.havdalah,
|
||||||
"""Run when entity will be removed from hass."""
|
]
|
||||||
if self._update_unsub:
|
|
||||||
self._update_unsub()
|
|
||||||
self._update_unsub = None
|
|
||||||
return await super().async_will_remove_from_hass()
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _update(self, now: dt.datetime | None = None) -> None:
|
|
||||||
"""Update the state of the sensor."""
|
|
||||||
self._update_unsub = None
|
|
||||||
self._schedule_update()
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
def _schedule_update(self) -> None:
|
|
||||||
"""Schedule the next update of the sensor."""
|
|
||||||
now = dt_util.now()
|
|
||||||
zmanim = self.make_zmanim(dt.date.today())
|
|
||||||
update = zmanim.netz_hachama.local + dt.timedelta(days=1)
|
|
||||||
candle_lighting = zmanim.candle_lighting
|
|
||||||
if candle_lighting is not None and now < candle_lighting < update:
|
|
||||||
update = candle_lighting
|
|
||||||
havdalah = zmanim.havdalah
|
|
||||||
if havdalah is not None and now < havdalah < update:
|
|
||||||
update = havdalah
|
|
||||||
if self._update_unsub:
|
|
||||||
self._update_unsub()
|
|
||||||
self._update_unsub = event.async_track_point_in_time(
|
|
||||||
self.hass, self._update, update
|
|
||||||
)
|
|
||||||
|
@ -1,17 +1,24 @@
|
|||||||
"""Entity representing a Jewish Calendar sensor."""
|
"""Entity representing a Jewish Calendar sensor."""
|
||||||
|
|
||||||
|
from abc import abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
|
import logging
|
||||||
|
|
||||||
from hdate import HDateInfo, Location, Zmanim
|
from hdate import HDateInfo, Location, Zmanim
|
||||||
from hdate.translator import Language, set_language
|
from hdate.translator import Language, set_language
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, callback
|
||||||
|
from homeassistant.helpers import event
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||||
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
|
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
|
||||||
|
|
||||||
|
|
||||||
@ -39,6 +46,8 @@ class JewishCalendarEntity(Entity):
|
|||||||
"""An HA implementation for Jewish Calendar entity."""
|
"""An HA implementation for Jewish Calendar entity."""
|
||||||
|
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
_attr_should_poll = False
|
||||||
|
_update_unsub: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -63,3 +72,55 @@ class JewishCalendarEntity(Entity):
|
|||||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||||
havdalah_offset=self.data.havdalah_offset,
|
havdalah_offset=self.data.havdalah_offset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def async_added_to_hass(self) -> None:
|
||||||
|
"""Call when entity is added to hass."""
|
||||||
|
await super().async_added_to_hass()
|
||||||
|
self._schedule_update()
|
||||||
|
|
||||||
|
async def async_will_remove_from_hass(self) -> None:
|
||||||
|
"""Run when entity will be removed from hass."""
|
||||||
|
if self._update_unsub:
|
||||||
|
self._update_unsub()
|
||||||
|
self._update_unsub = None
|
||||||
|
return await super().async_will_remove_from_hass()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||||
|
"""Return a list of times to update the sensor."""
|
||||||
|
|
||||||
|
def _schedule_update(self) -> None:
|
||||||
|
"""Schedule the next update of the sensor."""
|
||||||
|
now = dt_util.now()
|
||||||
|
zmanim = self.make_zmanim(now.date())
|
||||||
|
update = dt_util.start_of_local_day() + dt.timedelta(days=1)
|
||||||
|
|
||||||
|
for update_time in self._update_times(zmanim):
|
||||||
|
if update_time is not None and now < update_time < update:
|
||||||
|
update = update_time
|
||||||
|
|
||||||
|
if self._update_unsub:
|
||||||
|
self._update_unsub()
|
||||||
|
self._update_unsub = event.async_track_point_in_time(
|
||||||
|
self.hass, self._update, update
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _update(self, now: dt.datetime | None = None) -> None:
|
||||||
|
"""Update the sensor data."""
|
||||||
|
self._update_unsub = None
|
||||||
|
self._schedule_update()
|
||||||
|
self.create_results(now)
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
def create_results(self, now: dt.datetime | None = None) -> None:
|
||||||
|
"""Create the results for the sensor."""
|
||||||
|
if now is None:
|
||||||
|
now = dt_util.now()
|
||||||
|
|
||||||
|
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
|
||||||
|
|
||||||
|
today = now.date()
|
||||||
|
zmanim = self.make_zmanim(today)
|
||||||
|
dateinfo = HDateInfo(today, diaspora=self.data.diaspora)
|
||||||
|
self.data.results = JewishCalendarDataResults(dateinfo, zmanim)
|
||||||
|
@ -17,16 +17,11 @@ from homeassistant.components.sensor import (
|
|||||||
SensorEntityDescription,
|
SensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import event
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
|
||||||
from .entity import (
|
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
|
||||||
JewishCalendarConfigEntry,
|
|
||||||
JewishCalendarDataResults,
|
|
||||||
JewishCalendarEntity,
|
|
||||||
)
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
PARALLEL_UPDATES = 0
|
PARALLEL_UPDATES = 0
|
||||||
@ -217,7 +212,7 @@ async def async_setup_entry(
|
|||||||
config_entry: JewishCalendarConfigEntry,
|
config_entry: JewishCalendarConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Jewish calendar sensors ."""
|
"""Set up the Jewish calendar sensors."""
|
||||||
sensors: list[JewishCalendarBaseSensor] = [
|
sensors: list[JewishCalendarBaseSensor] = [
|
||||||
JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS
|
JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS
|
||||||
]
|
]
|
||||||
@ -231,59 +226,15 @@ async def async_setup_entry(
|
|||||||
class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
|
class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
|
||||||
"""Base class for Jewish calendar sensors."""
|
"""Base class for Jewish calendar sensors."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
|
||||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||||
_update_unsub: CALLBACK_TYPE | None = None
|
|
||||||
|
|
||||||
entity_description: JewishCalendarBaseSensorDescription
|
entity_description: JewishCalendarBaseSensorDescription
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||||
"""Call when entity is added to hass."""
|
"""Return a list of times to update the sensor."""
|
||||||
await super().async_added_to_hass()
|
if self.entity_description.next_update_fn is None:
|
||||||
self._schedule_update()
|
return []
|
||||||
|
return [self.entity_description.next_update_fn(zmanim)]
|
||||||
async def async_will_remove_from_hass(self) -> None:
|
|
||||||
"""Run when entity will be removed from hass."""
|
|
||||||
if self._update_unsub:
|
|
||||||
self._update_unsub()
|
|
||||||
self._update_unsub = None
|
|
||||||
return await super().async_will_remove_from_hass()
|
|
||||||
|
|
||||||
def _schedule_update(self) -> None:
|
|
||||||
"""Schedule the next update of the sensor."""
|
|
||||||
now = dt_util.now()
|
|
||||||
zmanim = self.make_zmanim(now.date())
|
|
||||||
update = None
|
|
||||||
if self.entity_description.next_update_fn:
|
|
||||||
update = self.entity_description.next_update_fn(zmanim)
|
|
||||||
next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1)
|
|
||||||
if update is None or now > update:
|
|
||||||
update = next_midnight
|
|
||||||
if self._update_unsub:
|
|
||||||
self._update_unsub()
|
|
||||||
self._update_unsub = event.async_track_point_in_time(
|
|
||||||
self.hass, self._update_data, update
|
|
||||||
)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def _update_data(self, now: dt.datetime | None = None) -> None:
|
|
||||||
"""Update the sensor data."""
|
|
||||||
self._update_unsub = None
|
|
||||||
self._schedule_update()
|
|
||||||
self.create_results(now)
|
|
||||||
self.async_write_ha_state()
|
|
||||||
|
|
||||||
def create_results(self, now: dt.datetime | None = None) -> None:
|
|
||||||
"""Create the results for the sensor."""
|
|
||||||
if now is None:
|
|
||||||
now = dt_util.now()
|
|
||||||
|
|
||||||
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
|
|
||||||
|
|
||||||
today = now.date()
|
|
||||||
zmanim = self.make_zmanim(today)
|
|
||||||
dateinfo = HDateInfo(today, diaspora=self.data.diaspora)
|
|
||||||
self.data.results = JewishCalendarDataResults(dateinfo, zmanim)
|
|
||||||
|
|
||||||
def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo:
|
def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo:
|
||||||
"""Get the next date info."""
|
"""Get the next date info."""
|
||||||
|
@ -50,7 +50,6 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
|||||||
today = now.date()
|
today = now.date()
|
||||||
event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today)
|
event_date = get_astral_event_date(hass, SUN_EVENT_SUNSET, today)
|
||||||
if event_date is None:
|
if event_date is None:
|
||||||
_LOGGER.error("Can't get sunset event date for %s", today)
|
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
translation_domain=DOMAIN, translation_key="sunset_event"
|
translation_domain=DOMAIN, translation_key="sunset_event"
|
||||||
)
|
)
|
||||||
|
@ -13,8 +13,8 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/keymitt_ble",
|
"documentation": "https://www.home-assistant.io/integrations/keymitt_ble",
|
||||||
"integration_type": "hub",
|
"integration_type": "device",
|
||||||
"iot_class": "assumed_state",
|
"iot_class": "assumed_state",
|
||||||
"loggers": ["keymitt_ble"],
|
"loggers": ["keymitt_ble", "microbot"],
|
||||||
"requirements": ["PyMicroBot==0.0.17"]
|
"requirements": ["PyMicroBot==0.0.23"]
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
from collections.abc import AsyncGenerator
|
from collections.abc import AsyncGenerator
|
||||||
from typing import Any, Final, Literal
|
from typing import Any, Final, Literal
|
||||||
|
|
||||||
@ -20,8 +19,8 @@ from xknx.io.util import validate_ip as xknx_validate_ip
|
|||||||
from xknx.secure.keyring import Keyring, XMLInterface
|
from xknx.secure.keyring import Keyring, XMLInterface
|
||||||
|
|
||||||
from homeassistant.config_entries import (
|
from homeassistant.config_entries import (
|
||||||
|
SOURCE_RECONFIGURE,
|
||||||
ConfigEntry,
|
ConfigEntry,
|
||||||
ConfigEntryBaseFlow,
|
|
||||||
ConfigFlow,
|
ConfigFlow,
|
||||||
ConfigFlowResult,
|
ConfigFlowResult,
|
||||||
OptionsFlow,
|
OptionsFlow,
|
||||||
@ -103,12 +102,14 @@ _PORT_SELECTOR = vol.All(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
|
class KNXConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
"""Base class for KNX flows."""
|
"""Handle a KNX config flow."""
|
||||||
|
|
||||||
def __init__(self, initial_data: KNXConfigEntryData) -> None:
|
VERSION = 1
|
||||||
"""Initialize KNXCommonFlow."""
|
|
||||||
self.initial_data = initial_data
|
def __init__(self) -> None:
|
||||||
|
"""Initialize KNX config flow."""
|
||||||
|
self.initial_data = DEFAULT_ENTRY_DATA
|
||||||
self.new_entry_data = KNXConfigEntryData()
|
self.new_entry_data = KNXConfigEntryData()
|
||||||
self.new_title: str | None = None
|
self.new_title: str | None = None
|
||||||
|
|
||||||
@ -121,19 +122,21 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
|
|||||||
self._gatewayscanner: GatewayScanner | None = None
|
self._gatewayscanner: GatewayScanner | None = None
|
||||||
self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None
|
self._async_scan_gen: AsyncGenerator[GatewayDescriptor] | None = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@callback
|
||||||
|
def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow:
|
||||||
|
"""Get the options flow for this handler."""
|
||||||
|
return KNXOptionsFlow(config_entry)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _xknx(self) -> XKNX:
|
def _xknx(self) -> XKNX:
|
||||||
"""Return XKNX instance."""
|
"""Return XKNX instance."""
|
||||||
if isinstance(self, OptionsFlow) and (
|
if (self.source == SOURCE_RECONFIGURE) and (
|
||||||
knx_module := self.hass.data.get(KNX_MODULE_KEY)
|
knx_module := self.hass.data.get(KNX_MODULE_KEY)
|
||||||
):
|
):
|
||||||
return knx_module.xknx
|
return knx_module.xknx
|
||||||
return XKNX()
|
return XKNX()
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def finish_flow(self) -> ConfigFlowResult:
|
|
||||||
"""Finish the flow."""
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def connection_type(self) -> str:
|
def connection_type(self) -> str:
|
||||||
"""Return the configured connection type."""
|
"""Return the configured connection type."""
|
||||||
@ -150,6 +153,61 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
|
|||||||
self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
|
self.initial_data.get(CONF_KNX_TUNNEL_ENDPOINT_IA),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def finish_flow(self) -> ConfigFlowResult:
|
||||||
|
"""Create or update the ConfigEntry."""
|
||||||
|
if self.source == SOURCE_RECONFIGURE:
|
||||||
|
entry = self._get_reconfigure_entry()
|
||||||
|
_tunnel_endpoint_str = self.initial_data.get(
|
||||||
|
CONF_KNX_TUNNEL_ENDPOINT_IA, "Tunneling"
|
||||||
|
)
|
||||||
|
if self.new_title and not entry.title.startswith(
|
||||||
|
# Overwrite standard titles, but not user defined ones
|
||||||
|
(
|
||||||
|
f"KNX {self.initial_data[CONF_KNX_CONNECTION_TYPE]}",
|
||||||
|
CONF_KNX_AUTOMATIC.capitalize(),
|
||||||
|
"Tunneling @ ",
|
||||||
|
f"{_tunnel_endpoint_str} @",
|
||||||
|
"Tunneling UDP @ ",
|
||||||
|
"Tunneling TCP @ ",
|
||||||
|
"Secure Tunneling",
|
||||||
|
"Routing as ",
|
||||||
|
"Secure Routing as ",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
self.new_title = None
|
||||||
|
return self.async_update_reload_and_abort(
|
||||||
|
self._get_reconfigure_entry(),
|
||||||
|
data_updates=self.new_entry_data,
|
||||||
|
title=self.new_title or UNDEFINED,
|
||||||
|
)
|
||||||
|
|
||||||
|
title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}"
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=title,
|
||||||
|
data=DEFAULT_ENTRY_DATA | self.new_entry_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle a flow initialized by the user."""
|
||||||
|
return await self.async_step_connection_type()
|
||||||
|
|
||||||
|
async def async_step_reconfigure(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle reconfiguration of existing entry."""
|
||||||
|
entry = self._get_reconfigure_entry()
|
||||||
|
self.initial_data = dict(entry.data) # type: ignore[assignment]
|
||||||
|
return self.async_show_menu(
|
||||||
|
step_id="reconfigure",
|
||||||
|
menu_options=[
|
||||||
|
"connection_type",
|
||||||
|
"secure_knxkeys",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
async def async_step_connection_type(
|
async def async_step_connection_type(
|
||||||
self, user_input: dict | None = None
|
self, user_input: dict | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
@ -441,7 +499,7 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
|
|||||||
)
|
)
|
||||||
ip_address: str | None
|
ip_address: str | None
|
||||||
if ( # initial attempt on ConfigFlow or coming from automatic / routing
|
if ( # initial attempt on ConfigFlow or coming from automatic / routing
|
||||||
(isinstance(self, ConfigFlow) or not _reconfiguring_existing_tunnel)
|
not _reconfiguring_existing_tunnel
|
||||||
and not user_input
|
and not user_input
|
||||||
and self._selected_tunnel is not None
|
and self._selected_tunnel is not None
|
||||||
): # default to first found tunnel
|
): # default to first found tunnel
|
||||||
@ -841,52 +899,20 @@ class KNXCommonFlow(ABC, ConfigEntryBaseFlow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN):
|
class KNXOptionsFlow(OptionsFlow):
|
||||||
"""Handle a KNX config flow."""
|
|
||||||
|
|
||||||
VERSION = 1
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Initialize KNX options flow."""
|
|
||||||
super().__init__(initial_data=DEFAULT_ENTRY_DATA)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
@callback
|
|
||||||
def async_get_options_flow(config_entry: ConfigEntry) -> KNXOptionsFlow:
|
|
||||||
"""Get the options flow for this handler."""
|
|
||||||
return KNXOptionsFlow(config_entry)
|
|
||||||
|
|
||||||
@callback
|
|
||||||
def finish_flow(self) -> ConfigFlowResult:
|
|
||||||
"""Create the ConfigEntry."""
|
|
||||||
title = self.new_title or f"KNX {self.new_entry_data[CONF_KNX_CONNECTION_TYPE]}"
|
|
||||||
return self.async_create_entry(
|
|
||||||
title=title,
|
|
||||||
data=DEFAULT_ENTRY_DATA | self.new_entry_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_user(self, user_input: dict | None = None) -> ConfigFlowResult:
|
|
||||||
"""Handle a flow initialized by the user."""
|
|
||||||
return await self.async_step_connection_type()
|
|
||||||
|
|
||||||
|
|
||||||
class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
|
|
||||||
"""Handle KNX options."""
|
"""Handle KNX options."""
|
||||||
|
|
||||||
general_settings: dict
|
|
||||||
|
|
||||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||||
"""Initialize KNX options flow."""
|
"""Initialize KNX options flow."""
|
||||||
super().__init__(initial_data=config_entry.data) # type: ignore[arg-type]
|
self.initial_data = dict(config_entry.data)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def finish_flow(self) -> ConfigFlowResult:
|
def finish_flow(self, new_entry_data: KNXConfigEntryData) -> ConfigFlowResult:
|
||||||
"""Update the ConfigEntry and finish the flow."""
|
"""Update the ConfigEntry and finish the flow."""
|
||||||
new_data = DEFAULT_ENTRY_DATA | self.initial_data | self.new_entry_data
|
new_data = self.initial_data | new_entry_data
|
||||||
self.hass.config_entries.async_update_entry(
|
self.hass.config_entries.async_update_entry(
|
||||||
self.config_entry,
|
self.config_entry,
|
||||||
data=new_data,
|
data=new_data,
|
||||||
title=self.new_title or UNDEFINED,
|
|
||||||
)
|
)
|
||||||
return self.async_create_entry(title="", data={})
|
return self.async_create_entry(title="", data={})
|
||||||
|
|
||||||
@ -894,26 +920,20 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Manage KNX options."""
|
"""Manage KNX options."""
|
||||||
return self.async_show_menu(
|
return await self.async_step_communication_settings()
|
||||||
step_id="init",
|
|
||||||
menu_options=[
|
|
||||||
"connection_type",
|
|
||||||
"communication_settings",
|
|
||||||
"secure_knxkeys",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
async def async_step_communication_settings(
|
async def async_step_communication_settings(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Manage KNX communication settings."""
|
"""Manage KNX communication settings."""
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self.new_entry_data = KNXConfigEntryData(
|
return self.finish_flow(
|
||||||
state_updater=user_input[CONF_KNX_STATE_UPDATER],
|
KNXConfigEntryData(
|
||||||
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
|
state_updater=user_input[CONF_KNX_STATE_UPDATER],
|
||||||
telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE],
|
rate_limit=user_input[CONF_KNX_RATE_LIMIT],
|
||||||
|
telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE],
|
||||||
|
)
|
||||||
)
|
)
|
||||||
return self.finish_flow()
|
|
||||||
|
|
||||||
data_schema = {
|
data_schema = {
|
||||||
vol.Required(
|
vol.Required(
|
||||||
|
@ -104,7 +104,7 @@ rules:
|
|||||||
Since all entities are configured manually, names are user-defined.
|
Since all entities are configured manually, names are user-defined.
|
||||||
exception-translations: done
|
exception-translations: done
|
||||||
icon-translations: done
|
icon-translations: done
|
||||||
reconfiguration-flow: todo
|
reconfiguration-flow: done
|
||||||
repair-issues: todo
|
repair-issues: todo
|
||||||
stale-devices:
|
stale-devices:
|
||||||
status: exempt
|
status: exempt
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
{
|
{
|
||||||
"config": {
|
"config": {
|
||||||
"step": {
|
"step": {
|
||||||
|
"reconfigure": {
|
||||||
|
"title": "KNX connection settings",
|
||||||
|
"menu_options": {
|
||||||
|
"connection_type": "Reconfigure KNX connection",
|
||||||
|
"secure_knxkeys": "Import KNX keyring file"
|
||||||
|
}
|
||||||
|
},
|
||||||
"connection_type": {
|
"connection_type": {
|
||||||
"title": "KNX connection",
|
"title": "KNX connection",
|
||||||
"description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.",
|
"description": "'Automatic' performs a gateway scan on start, to find a KNX IP interface. It will connect via a tunnel. (Not available if a gateway scan was not successful.)\n\n'Tunneling' will connect to a specific KNX IP interface over a tunnel.\n\n'Routing' will use Multicast to communicate with KNX IP routers.",
|
||||||
@ -65,7 +72,7 @@
|
|||||||
},
|
},
|
||||||
"secure_knxkeys": {
|
"secure_knxkeys": {
|
||||||
"title": "Import KNX Keyring",
|
"title": "Import KNX Keyring",
|
||||||
"description": "The Keyring is used to encrypt and decrypt KNX IP Secure communication.",
|
"description": "The keyring is used to encrypt and decrypt KNX IP Secure communication. You can import a new keyring file or re-import to update existing keys if your configuration has changed.",
|
||||||
"data": {
|
"data": {
|
||||||
"knxkeys_file": "Keyring file",
|
"knxkeys_file": "Keyring file",
|
||||||
"knxkeys_password": "Keyring password"
|
"knxkeys_password": "Keyring password"
|
||||||
@ -129,6 +136,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"abort": {
|
||||||
|
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
"invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.",
|
"invalid_backbone_key": "Invalid backbone key. 32 hexadecimal digits expected.",
|
||||||
@ -159,16 +169,8 @@
|
|||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"init": {
|
|
||||||
"title": "KNX Settings",
|
|
||||||
"menu_options": {
|
|
||||||
"connection_type": "Configure KNX interface",
|
|
||||||
"communication_settings": "Communication settings",
|
|
||||||
"secure_knxkeys": "Import a `.knxkeys` file"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"communication_settings": {
|
"communication_settings": {
|
||||||
"title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]",
|
"title": "Communication settings",
|
||||||
"data": {
|
"data": {
|
||||||
"state_updater": "State updater",
|
"state_updater": "State updater",
|
||||||
"rate_limit": "Rate limit",
|
"rate_limit": "Rate limit",
|
||||||
@ -179,147 +181,7 @@
|
|||||||
"rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`",
|
"rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: `0` or between `20` and `40`",
|
||||||
"telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
|
"telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"connection_type": {
|
|
||||||
"title": "[%key:component::knx::config::step::connection_type::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::connection_type::description%]",
|
|
||||||
"data": {
|
|
||||||
"connection_type": "[%key:component::knx::config::step::connection_type::data::connection_type%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"connection_type": "[%key:component::knx::config::step::connection_type::data_description::connection_type%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tunnel": {
|
|
||||||
"title": "[%key:component::knx::config::step::tunnel::title%]",
|
|
||||||
"data": {
|
|
||||||
"gateway": "[%key:component::knx::config::step::tunnel::data::gateway%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"gateway": "[%key:component::knx::config::step::tunnel::data_description::gateway%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"tcp_tunnel_endpoint": {
|
|
||||||
"title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
|
|
||||||
"data": {
|
|
||||||
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"manual_tunnel": {
|
|
||||||
"title": "[%key:component::knx::config::step::manual_tunnel::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::manual_tunnel::description%]",
|
|
||||||
"data": {
|
|
||||||
"tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data::tunneling_type%]",
|
|
||||||
"port": "[%key:common::config_flow::data::port%]",
|
|
||||||
"host": "[%key:common::config_flow::data::host%]",
|
|
||||||
"route_back": "[%key:component::knx::config::step::manual_tunnel::data::route_back%]",
|
|
||||||
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"tunneling_type": "[%key:component::knx::config::step::manual_tunnel::data_description::tunneling_type%]",
|
|
||||||
"port": "[%key:component::knx::config::step::manual_tunnel::data_description::port%]",
|
|
||||||
"host": "[%key:component::knx::config::step::manual_tunnel::data_description::host%]",
|
|
||||||
"route_back": "[%key:component::knx::config::step::manual_tunnel::data_description::route_back%]",
|
|
||||||
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secure_key_source_menu_tunnel": {
|
|
||||||
"title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]",
|
|
||||||
"menu_options": {
|
|
||||||
"secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]",
|
|
||||||
"secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secure_key_source_menu_routing": {
|
|
||||||
"title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]",
|
|
||||||
"menu_options": {
|
|
||||||
"secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]",
|
|
||||||
"secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secure_knxkeys": {
|
|
||||||
"title": "[%key:component::knx::config::step::secure_knxkeys::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::secure_knxkeys::description%]",
|
|
||||||
"data": {
|
|
||||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_file%]",
|
|
||||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data::knxkeys_password%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"knxkeys_file": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_file%]",
|
|
||||||
"knxkeys_password": "[%key:component::knx::config::step::secure_knxkeys::data_description::knxkeys_password%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"knxkeys_tunnel_select": {
|
|
||||||
"title": "[%key:component::knx::config::step::tcp_tunnel_endpoint::title%]",
|
|
||||||
"data": {
|
|
||||||
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data::tunnel_endpoint_ia%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"tunnel_endpoint_ia": "[%key:component::knx::config::step::tcp_tunnel_endpoint::data_description::tunnel_endpoint_ia%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secure_tunnel_manual": {
|
|
||||||
"title": "[%key:component::knx::config::step::secure_tunnel_manual::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]",
|
|
||||||
"data": {
|
|
||||||
"user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_id%]",
|
|
||||||
"user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data::user_password%]",
|
|
||||||
"device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data::device_authentication%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"user_id": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_id%]",
|
|
||||||
"user_password": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::user_password%]",
|
|
||||||
"device_authentication": "[%key:component::knx::config::step::secure_tunnel_manual::data_description::device_authentication%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secure_routing_manual": {
|
|
||||||
"title": "[%key:component::knx::config::step::secure_routing_manual::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]",
|
|
||||||
"data": {
|
|
||||||
"backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]",
|
|
||||||
"sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data_description::backbone_key%]",
|
|
||||||
"sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data_description::sync_latency_tolerance%]"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"routing": {
|
|
||||||
"title": "[%key:component::knx::config::step::routing::title%]",
|
|
||||||
"description": "[%key:component::knx::config::step::routing::description%]",
|
|
||||||
"data": {
|
|
||||||
"individual_address": "[%key:component::knx::config::step::routing::data::individual_address%]",
|
|
||||||
"routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]",
|
|
||||||
"multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]",
|
|
||||||
"multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]",
|
|
||||||
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]"
|
|
||||||
},
|
|
||||||
"data_description": {
|
|
||||||
"individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]",
|
|
||||||
"routing_secure": "[%key:component::knx::config::step::routing::data_description::routing_secure%]",
|
|
||||||
"multicast_group": "[%key:component::knx::config::step::routing::data_description::multicast_group%]",
|
|
||||||
"multicast_port": "[%key:component::knx::config::step::routing::data_description::multicast_port%]",
|
|
||||||
"local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
|
||||||
"invalid_backbone_key": "[%key:component::knx::config::error::invalid_backbone_key%]",
|
|
||||||
"invalid_individual_address": "[%key:component::knx::config::error::invalid_individual_address%]",
|
|
||||||
"invalid_ip_address": "[%key:component::knx::config::error::invalid_ip_address%]",
|
|
||||||
"keyfile_no_backbone_key": "[%key:component::knx::config::error::keyfile_no_backbone_key%]",
|
|
||||||
"keyfile_invalid_signature": "[%key:component::knx::config::error::keyfile_invalid_signature%]",
|
|
||||||
"keyfile_no_tunnel_for_host": "[%key:component::knx::config::error::keyfile_no_tunnel_for_host%]",
|
|
||||||
"keyfile_not_found": "[%key:component::knx::config::error::keyfile_not_found%]",
|
|
||||||
"no_router_discovered": "[%key:component::knx::config::error::no_router_discovered%]",
|
|
||||||
"no_tunnel_discovered": "[%key:component::knx::config::error::no_tunnel_discovered%]",
|
|
||||||
"unsupported_tunnel_type": "[%key:component::knx::config::error::unsupported_tunnel_type%]"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"entity": {
|
"entity": {
|
||||||
|
@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
import inspect
|
||||||
from typing import TYPE_CHECKING, Any, Final, overload
|
from typing import TYPE_CHECKING, Any, Final, overload
|
||||||
|
|
||||||
import knx_frontend as knx_panel
|
import knx_frontend as knx_panel
|
||||||
@ -116,7 +116,7 @@ def provide_knx(
|
|||||||
"KNX integration not loaded.",
|
"KNX integration not loaded.",
|
||||||
)
|
)
|
||||||
|
|
||||||
if asyncio.iscoroutinefunction(func):
|
if inspect.iscoroutinefunction(func):
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
async def with_knx(
|
async def with_knx(
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user