This commit is contained in:
Jonh Sady 2025-02-03 14:21:05 -03:00
commit e48d1907f4
378 changed files with 10704 additions and 3471 deletions

View File

@ -46,6 +46,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to frontend pull request:
## Checklist
<!--

4
CODEOWNERS generated
View File

@ -625,8 +625,8 @@ build.json @home-assistant/supervisor
/tests/components/hlk_sw16/ @jameshilliard
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
/tests/components/holiday/ @jrieger @gjohansson-ST
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98
/tests/components/home_connect/ @DavidMStraub @Diegorro98
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
/homeassistant/components/homeassistant/ @home-assistant/core
/tests/components/homeassistant/ @home-assistant/core
/homeassistant/components/homeassistant_alerts/ @home-assistant/core

View File

@ -161,6 +161,16 @@ FRONTEND_INTEGRATIONS = {
# integrations can be removed and database migration status is
# visible in frontend
"frontend",
# Hassio is an after dependency of backup, after dependencies
# are not promoted from stage 2 to earlier stages, so we need to
# add it here. Hassio needs to be setup before backup, otherwise
# the backup integration will think we are a container/core install
# when using HAOS or Supervised install.
"hassio",
# Backup is an after dependency of frontend, after dependencies
# are not promoted from stage 2 to earlier stages, so we need to
# add it here.
"backup",
}
RECORDER_INTEGRATIONS = {
# Setup after frontend

View File

@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["python_homeassistant_analytics"],
"requirements": ["python-homeassistant-analytics==0.8.1"],
"requirements": ["python-homeassistant-analytics==0.9.0"],
"single_config_entry": true
}

View File

@ -272,6 +272,7 @@ class AnthropicConversationEntity(
continue
tool_input = llm.ToolInput(
id=tool_call.id,
tool_name=tool_call.name,
tool_args=cast(dict[str, Any], tool_call.input),
)

View File

@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address][0], data={}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import chat_session
from homeassistant.helpers.typing import ConfigType
from .const import (
@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream(
Raises PipelineNotFound if no pipeline is found.
"""
pipeline_input = PipelineInput(
conversation_id=conversation_id,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
start_stage=start_stage,
end_stage=end_stage,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
wake_word_settings=wake_word_settings,
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.validate()
await pipeline_input.execute()
with chat_session.async_get_chat_session(hass, conversation_id) as session:
pipeline_input = PipelineInput(
conversation_id=session.conversation_id,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
start_stage=start_stage,
end_stage=end_stage,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
wake_word_settings=wake_word_settings,
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.validate()
await pipeline_input.execute()

View File

@ -33,7 +33,7 @@ from homeassistant.components.tts import (
from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.helpers import chat_session, intent
from homeassistant.helpers.collection import (
CHANGE_UPDATED,
CollectionError,
@ -624,7 +624,7 @@ class PipelineRun:
return
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event)
def start(self, device_id: str | None) -> None:
def start(self, conversation_id: str, device_id: str | None) -> None:
"""Emit run start event."""
self._device_id = device_id
self._start_debug_recording_thread()
@ -632,6 +632,7 @@ class PipelineRun:
data = {
"pipeline": self.pipeline.id,
"language": self.language,
"conversation_id": conversation_id,
}
if self.runner_data is not None:
data["runner_data"] = self.runner_data
@ -1015,7 +1016,7 @@ class PipelineRun:
async def recognize_intent(
self,
intent_input: str,
conversation_id: str | None,
conversation_id: str,
device_id: str | None,
conversation_extra_system_prompt: str | None,
) -> str:
@ -1063,11 +1064,11 @@ class PipelineRun:
agent_id=self.intent_agent,
extra_system_prompt=conversation_extra_system_prompt,
)
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
agent_id = user_input.agent_id
agent_id = self.intent_agent
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
intent_response: intent.IntentResponse | None = None
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
if not processed_locally:
# Sentence triggers override conversation agent
if (
trigger_response_text
@ -1094,22 +1095,27 @@ class PipelineRun:
# It was already handled, create response and add to chat history
if intent_response is not None:
async with conversation.async_get_chat_session(
self.hass, user_input
) as chat_session:
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(
self.hass, session, user_input
) as chat_log,
):
speech: str = intent_response.speech.get("plain", {}).get(
"speech", ""
)
chat_session.async_add_message(
conversation.Content(
role="assistant",
async for _ in chat_log.async_add_assistant_content(
conversation.AssistantContent(
agent_id=agent_id,
content=speech,
)
)
):
pass
conversation_result = conversation.ConversationResult(
response=intent_response,
conversation_id=chat_session.conversation_id,
conversation_id=session.conversation_id,
)
else:
@ -1404,12 +1410,15 @@ def _pipeline_debug_recording_thread_proc(
wav_writer.close()
@dataclass
@dataclass(kw_only=True)
class PipelineInput:
"""Input to a pipeline run."""
run: PipelineRun
conversation_id: str
"""Identifier for the conversation."""
stt_metadata: stt.SpeechMetadata | None = None
"""Metadata of stt input audio. Required when start_stage = stt."""
@ -1425,9 +1434,6 @@ class PipelineInput:
tts_input: str | None = None
"""Input for text-to-speech. Required when start_stage = tts."""
conversation_id: str | None = None
"""Identifier for the conversation."""
conversation_extra_system_prompt: str | None = None
"""Extra prompt information for the conversation agent."""
@ -1436,7 +1442,7 @@ class PipelineInput:
async def execute(self) -> None:
"""Run pipeline."""
self.run.start(device_id=self.device_id)
self.run.start(conversation_id=self.conversation_id, device_id=self.device_id)
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None

View File

@ -14,7 +14,11 @@ import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import (
chat_session,
config_validation as cv,
entity_registry as er,
)
from homeassistant.util import language as language_util
from .const import (
@ -145,7 +149,6 @@ async def websocket_run(
# Arguments to PipelineInput
input_args: dict[str, Any] = {
"conversation_id": msg.get("conversation_id"),
"device_id": msg.get("device_id"),
}
@ -233,38 +236,42 @@ async def websocket_run(
audio_settings=audio_settings or AudioSettings(),
)
pipeline_input = PipelineInput(**input_args)
with chat_session.async_get_chat_session(
hass, msg.get("conversation_id")
) as session:
input_args["conversation_id"] = session.conversation_id
pipeline_input = PipelineInput(**input_args)
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
# Confirm subscription
connection.send_result(msg["id"])
# Confirm subscription
connection.send_result(msg["id"])
run_task = hass.async_create_task(pipeline_input.execute())
run_task = hass.async_create_task(pipeline_input.execute())
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
try:
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
try:
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
)
)
)
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
@callback

View File

@ -8,7 +8,7 @@ from dataclasses import dataclass
from enum import StrEnum
import logging
import time
from typing import Any, Final, Literal, final
from typing import Any, Literal, final
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
@ -28,14 +28,12 @@ from homeassistant.components.tts import (
)
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription
from .const import AssistSatelliteEntityFeature
from .errors import AssistSatelliteError, SatelliteBusyError
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
_LOGGER = logging.getLogger(__name__)
@ -114,7 +112,6 @@ class AssistSatelliteEntity(entity.Entity):
_attr_vad_sensitivity_entity_id: str | None = None
_conversation_id: str | None = None
_conversation_id_time: float | None = None
_run_has_tts: bool = False
_is_announcing = False
@ -260,11 +257,25 @@ class AssistSatelliteEntity(entity.Entity):
else:
self._extra_system_prompt = start_message or None
with (
# Not passing in a conversation ID will force a new one to be created
chat_session.async_get_chat_session(self.hass) as session,
conversation.async_get_chat_log(self.hass, session) as chat_log,
):
self._conversation_id = session.conversation_id
if start_message:
async for _tool_response in chat_log.async_add_assistant_content(
conversation.AssistantContent(
agent_id=self.entity_id, content=start_message
)
):
pass # no tool responses.
try:
await self.async_start_conversation(announcement)
finally:
self._is_announcing = False
self._extra_system_prompt = None
async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement
@ -282,6 +293,10 @@ class AssistSatelliteEntity(entity.Entity):
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline()
# Consume system prompt in first pipeline
extra_system_prompt = self._extra_system_prompt
self._extra_system_prompt = None
if self._wake_word_intercept_future and start_stage in (
PipelineStage.WAKE_WORD,
PipelineStage.STT,
@ -322,51 +337,52 @@ class AssistSatelliteEntity(entity.Entity):
assert self._context is not None
# Reset conversation id if necessary
if self._conversation_id_time and (
(time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC
):
self._conversation_id = None
self._conversation_id_time = None
# Set entity state based on pipeline events
self._run_has_tts = False
assert self.platform.config_entry is not None
self._pipeline_task = self.platform.config_entry.async_create_background_task(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_stream,
pipeline_id=self._resolve_pipeline(),
conversation_id=self._conversation_id,
device_id=device_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
silence_seconds=self._resolve_vad_sensitivity()
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=self._extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
try:
await self._pipeline_task
finally:
self._pipeline_task = None
with chat_session.async_get_chat_session(
self.hass, self._conversation_id
) as session:
# Store the conversation ID. If it is no longer valid, get_chat_session will reset it
self._conversation_id = session.conversation_id
self._pipeline_task = (
self.platform.config_entry.async_create_background_task(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_stream,
pipeline_id=self._resolve_pipeline(),
conversation_id=session.conversation_id,
device_id=device_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
silence_seconds=self._resolve_vad_sensitivity()
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
)
try:
await self._pipeline_task
finally:
self._pipeline_task = None
async def _cancel_running_pipeline(self) -> None:
"""Cancel the current pipeline if it's running."""
@ -390,11 +406,6 @@ class AssistSatelliteEntity(entity.Entity):
self._set_state(AssistSatelliteState.LISTENING)
elif event.type is PipelineEventType.INTENT_START:
self._set_state(AssistSatelliteState.PROCESSING)
elif event.type is PipelineEventType.INTENT_END:
assert event.data is not None
# Update timeout
self._conversation_id_time = time.monotonic()
self._conversation_id = event.data["intent_output"]["conversation_id"]
elif event.type is PipelineEventType.TTS_START:
# Wait until tts_response_finished is called to return to waiting state
self._run_has_tts = True

View File

@ -14,7 +14,7 @@
"services": {
"announce": {
"name": "Announce",
"description": "Let the satellite announce a message.",
"description": "Lets a satellite announce a message.",
"fields": {
"message": {
"name": "Message",
@ -27,8 +27,8 @@
}
},
"start_conversation": {
"name": "Start Conversation",
"description": "Start a conversation from a satellite.",
"name": "Start conversation",
"description": "Starts a conversation from a satellite.",
"fields": {
"start_message": {
"name": "Message",

View File

@ -35,6 +35,7 @@ from .manager import (
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, Folder
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
__all__ = [
@ -58,6 +59,8 @@ __all__ = [
"RestoreBackupState",
"WrittenBackup",
"async_get_manager",
"suggested_filename",
"suggested_filename_from_name_date",
]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)

View File

@ -14,7 +14,7 @@ from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup
from .util import read_backup
from .util import read_backup, suggested_filename
async def async_get_backup_agents(
@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup."""
return self._backup_dir / f"{backup.backup_id}.tar"
return self._backup_dir / suggested_filename(backup)
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file."""

View File

@ -2,8 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace
import datetime as dt
from datetime import datetime, timedelta
@ -252,7 +250,7 @@ class RetentionConfig:
"""Delete backups older than days."""
self._schedule_next(manager)
def _backups_filter(
def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return backups older than days to delete."""
@ -269,7 +267,9 @@ class RetentionConfig:
< now
}
await _delete_filtered_backups(manager, _backups_filter)
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)
manager.remove_next_delete_event = async_call_later(
manager.hass, timedelta(days=1), _delete_backups
@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False):
password: str | None
async def _delete_filtered_backups(
manager: BackupManager,
backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
) -> None:
"""Delete backups parsed with a filter.
:param manager: The backup manager.
:param backup_filter: A filter that should return the backups to delete.
"""
backups, get_agent_errors = await manager.async_get_backups()
if get_agent_errors:
LOGGER.debug(
"Error getting backups; continuing anyway: %s",
get_agent_errors,
)
# only delete backups that are created with the saved automatic settings
backups = {
def _automatic_backups_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return automatic backups."""
return {
backup_id: backup
for backup_id, backup in backups.items()
if backup.with_automatic_settings
}
LOGGER.debug("Total automatic backups: %s", backups)
filtered_backups = backup_filter(backups)
if not filtered_backups:
return
# always delete oldest backup first
filtered_backups = dict(
sorted(
filtered_backups.items(),
key=lambda backup_item: backup_item[1].date,
)
)
if len(filtered_backups) >= len(backups):
# Never delete the last backup.
last_backup = filtered_backups.popitem()
LOGGER.debug("Keeping the last backup: %s", last_backup)
LOGGER.debug("Backups to delete: %s", filtered_backups)
if not filtered_backups:
return
backup_ids = list(filtered_backups)
delete_results = await asyncio.gather(
*(manager.async_delete_backup(backup_id) for backup_id in filtered_backups)
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error
}
if agent_errors:
LOGGER.error(
"Error deleting old copies: %s",
agent_errors,
)
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
"""Delete backups exceeding the configured retention count."""
def _backups_filter(
def _delete_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete."""
@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
)
await _delete_filtered_backups(manager, _backups_filter)
await manager.async_delete_filtered_backups(
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
)

View File

@ -685,6 +685,70 @@ class BackupManager:
return agent_errors
async def async_delete_filtered_backups(
self,
*,
include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
) -> None:
"""Delete backups parsed with a filter.
:param include_filter: A filter that should return the backups to consider for
deletion. Note: The newest of the backups returned by include_filter will
unconditionally be kept, even if delete_filter returns all backups.
:param delete_filter: A filter that should return the backups to delete.
"""
backups, get_agent_errors = await self.async_get_backups()
if get_agent_errors:
LOGGER.debug(
"Error getting backups; continuing anyway: %s",
get_agent_errors,
)
# Run the include filter first to ensure we only consider backups that
# should be included in the deletion process.
backups = include_filter(backups)
LOGGER.debug("Total automatic backups: %s", backups)
backups_to_delete = delete_filter(backups)
if not backups_to_delete:
return
# always delete oldest backup first
backups_to_delete = dict(
sorted(
backups_to_delete.items(),
key=lambda backup_item: backup_item[1].date,
)
)
if len(backups_to_delete) >= len(backups):
# Never delete the last backup.
last_backup = backups_to_delete.popitem()
LOGGER.debug("Keeping the last backup: %s", last_backup)
LOGGER.debug("Backups to delete: %s", backups_to_delete)
if not backups_to_delete:
return
backup_ids = list(backups_to_delete)
delete_results = await asyncio.gather(
*(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
)
agent_errors = {
backup_id: error
for backup_id, error in zip(backup_ids, delete_results, strict=True)
if error
}
if agent_errors:
LOGGER.error(
"Error deleting old copies: %s",
agent_errors,
)
async def async_receive_backup(
self,
*,
@ -898,7 +962,7 @@ class BackupManager:
)
backup_name = (
name
(name if name is None else name.strip())
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
)
extra_metadata = extra_metadata or {}

View File

@ -20,6 +20,7 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError
from homeassistant.backup_restore import password_to_key
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from homeassistant.util.thread import ThreadWithException
@ -117,6 +118,17 @@ def read_backup(backup_path: Path) -> AgentBackup:
)
def suggested_filename_from_name_date(name: str, date_str: str) -> str:
"""Suggest a filename for the backup."""
date = dt_util.parse_datetime(date_str, raise_on_error=True)
return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
def suggested_filename(backup: AgentBackup) -> str:
"""Suggest a filename for the backup."""
return suggested_filename_from_name_date(backup.name, backup.date)
def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:

View File

@ -199,7 +199,7 @@ async def handle_can_decrypt_on_download(
vol.Optional("include_database", default=True): bool,
vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool,
vol.Optional("name"): str,
vol.Optional("name"): vol.Any(str, None),
vol.Optional("password"): vol.Any(str, None),
}
)

View File

@ -19,6 +19,8 @@ from .const import (
)
from .entity import BangOlufsenEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,

View File

@ -80,6 +80,7 @@ from .const import (
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -297,7 +298,12 @@ async def async_discover_adapters(
async def async_update_device(
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
hass: HomeAssistant,
entry: ConfigEntry,
adapter: str,
details: AdapterDetails,
via_device_domain: str | None = None,
via_device_id: str | None = None,
) -> None:
"""Update device registry entry.
@ -306,7 +312,8 @@ async def async_update_device(
update the device with the new location so they can
figure out where the adapter is.
"""
dr.async_get(hass).async_get_or_create(
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
@ -315,6 +322,10 @@ async def async_update_device(
sw_version=details.get(ADAPTER_SW_VERSION),
hw_version=details.get(ADAPTER_HW_VERSION),
)
if via_device_id:
device_registry.async_update_device(
device_entry.id, via_device_id=via_device_id
)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
source_entry.title,
details,
source_domain,
entry.data.get(CONF_SOURCE_DEVICE_ID),
)
return True
manager = _get_manager(hass)

View File

@ -181,10 +181,16 @@ def async_register_scanner(
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
return _get_manager(hass).async_register_hass_scanner(
scanner, connection_slots, source_domain, source_model, source_config_entry_id
scanner,
connection_slots,
source_domain,
source_model,
source_config_entry_id,
source_device_id,
)

View File

@ -37,6 +37,7 @@ from .const import (
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
}
self._abort_if_unique_id_configured(updates=data)
manager = get_manager()

View File

@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model"
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
SOURCE_LOCAL: Final = "local"

View File

@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a scanner."""
cancel = self.async_register_scanner(scanner, connection_slots)
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
isinstance(scanner, BaseHaRemoteScanner)
and source_domain
and source_config_entry_id
and not self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, scanner.source
)
):
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
CONF_SOURCE_DOMAIN: source_domain,
CONF_SOURCE_MODEL: source_model,
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
CONF_SOURCE_DEVICE_ID: source_device_id,
},
)
)

View File

@ -19,8 +19,8 @@
"bleak-retry-connector==3.8.0",
"bluetooth-adapters==0.21.1",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.22.0",
"dbus-fast==2.30.2",
"habluetooth==3.14.0"
"bluetooth-data-tools==1.23.3",
"dbus-fast==2.32.0",
"habluetooth==3.21.0"
]
}

View File

@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EMAIL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
config_entry: ConfigEntry
user_settings: BringUserSettingsResponse
lists: list[BringList]
def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
"""Initialize the Bring data coordinator."""
@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
update_interval=timedelta(seconds=90),
)
self.bring = bring
self.previous_lists: set[str] = set()
async def _async_update_data(self) -> dict[str, BringData]:
"""Fetch the latest data from bring."""
try:
lists_response = await self.bring.load_lists()
self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e:
@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
) from exc
return self.data
if self.previous_lists - (
current_lists := {lst.listUuid for lst in self.lists}
):
self._purge_deleted_lists()
self.previous_lists = current_lists
list_dict: dict[str, BringData] = {}
for lst in lists_response.lists:
for lst in self.lists:
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
continue
try:
@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
try:
await self.bring.login()
self.user_settings = await self.bring.get_all_user_settings()
self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.bring.mail},
) from e
self._purge_deleted_lists()
def _purge_deleted_lists(self) -> None:
"""Purge device entries of deleted lists."""
device_reg = dr.async_get(self.hass)
identifiers = {
(DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}")
for lst in self.lists
}
for device in dr.async_entries_for_config_entry(
device_reg, self.config_entry.entry_id
):
if not set(device.identifiers) & identifiers:
_LOGGER.debug("Removing obsolete device entry %s", device.name)
device_reg.async_update_device(
device.id, remove_config_entry_id=self.config_entry.entry_id
)

View File

@ -2,11 +2,13 @@
from __future__ import annotations
from bring_api.types import BringList
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BringData, BringDataUpdateCoordinator
from .coordinator import BringDataUpdateCoordinator
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
def __init__(
self,
coordinator: BringDataUpdateCoordinator,
bring_list: BringData,
bring_list: BringList,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, bring_list.lst.listUuid)
super().__init__(coordinator, bring_list.listUuid)
self._list_uuid = bring_list.lst.listUuid
self._list_uuid = bring_list.listUuid
self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
name=bring_list.lst.name,
name=bring_list.name,
identifiers={
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
},
manufacturer="Bring! Labs AG",
model="Bring! Grocery Shopping List",
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}",
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}",
)

View File

@ -53,7 +53,7 @@ rules:
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@ -65,7 +65,7 @@ rules:
status: exempt
comment: |
no repairs
stale-devices: todo
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done

View File

@ -8,6 +8,7 @@ from enum import StrEnum
from bring_api import BringUserSettingsResponse
from bring_api.const import BRING_SUPPORTED_LOCALES
from bring_api.types import BringList
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -15,7 +16,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import StateType
@ -90,16 +91,28 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
lists_added: set[str] = set()
async_add_entities(
BringSensorEntity(
coordinator,
bring_list,
description,
)
for description in SENSOR_DESCRIPTIONS
for bring_list in coordinator.data.values()
)
@callback
def add_entities() -> None:
"""Add sensor entities."""
nonlocal lists_added
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
async_add_entities(
BringSensorEntity(
coordinator,
bring_list,
description,
)
for description in SENSOR_DESCRIPTIONS
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
class BringSensorEntity(BringBaseEntity, SensorEntity):
@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
def __init__(
self,
coordinator: BringDataUpdateCoordinator,
bring_list: BringData,
bring_list: BringList,
entity_description: BringSensorEntityDescription,
) -> None:
"""Initialize the entity."""

View File

@ -12,6 +12,7 @@ from bring_api import (
BringNotificationType,
BringRequestException,
)
from bring_api.types import BringList
import voluptuous as vol
from homeassistant.components.todo import (
@ -20,7 +21,7 @@ from homeassistant.components.todo import (
TodoListEntity,
TodoListEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -45,14 +46,23 @@ async def async_setup_entry(
) -> None:
"""Set up the sensor from a config entry created in the integrations UI."""
coordinator = config_entry.runtime_data
lists_added: set[str] = set()
async_add_entities(
BringTodoListEntity(
coordinator,
bring_list=bring_list,
)
for bring_list in coordinator.data.values()
)
@callback
def add_entities() -> None:
"""Add or remove todo list entities."""
nonlocal lists_added
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
async_add_entities(
BringTodoListEntity(coordinator, bring_list)
for bring_list in coordinator.lists
if bring_list.listUuid in new_lists
)
lists_added |= new_lists
coordinator.async_add_listener(add_entities)
add_entities()
platform = entity_platform.async_get_current_platform()
@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
)
def __init__(
self, coordinator: BringDataUpdateCoordinator, bring_list: BringData
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, bring_list)

View File

@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry()
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.9.1"]
"requirements": ["bthome-ble==3.12.3"]
}

View File

@ -67,6 +67,16 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
# Conductivity (µS/cm)
(
BTHomeSensorDeviceClass.CONDUCTIVITY,
Units.CONDUCTIVITY,
): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
device_class=SensorDeviceClass.CONDUCTIVITY,
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
state_class=SensorStateClass.MEASUREMENT,
),
# Count (-)
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
key=str(BTHomeSensorDeviceClass.COUNT),
@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
# Directions (°)
(BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription(
key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}",
native_unit_of_measurement=DEGREE,
state_class=SensorStateClass.MEASUREMENT,
),
# Distance (mm)
(
BTHomeSensorDeviceClass.DISTANCE,
@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
# Precipitation (mm)
(
BTHomeExtendedSensorDeviceClass.PRECIPITATION,
Units.LENGTH_MILLIMETERS,
): SensorEntityDescription(
key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}",
device_class=SensorDeviceClass.PRECIPITATION,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
),
# Pressure (mbar)
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.TOTAL,
),
# Conductivity (µS/cm)
(
BTHomeSensorDeviceClass.CONDUCTIVITY,
Units.CONDUCTIVITY,
): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
device_class=SensorDeviceClass.CONDUCTIVITY,
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
state_class=SensorStateClass.MEASUREMENT,
),
}

View File

@ -1175,12 +1175,17 @@ async def async_handle_snapshot_service(
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
)
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
image = (
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
if camera.use_stream_for_stills
else await camera.async_camera_image()
)
try:
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
image = (
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
if camera.use_stream_for_stills
else await camera.async_camera_image()
)
except TimeoutError as err:
raise HomeAssistantError(
f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds"
) from err
if image is None:
return
@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service(
try:
await hass.async_add_executor_job(_write_image, snapshot_file, image)
except OSError as err:
_LOGGER.error("Can't write image to file: %s", err)
raise HomeAssistantError(f"Can't write image to file: {err}") from err
async def async_handle_play_stream_service(

View File

@ -30,6 +30,16 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
from .chat_log import (
AssistantContent,
ChatLog,
Content,
ConverseError,
SystemContent,
ToolResultContent,
UserContent,
async_get_chat_log,
)
from .const import (
ATTR_AGENT_ID,
ATTR_CONVERSATION_ID,
@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
from .session import (
ChatSession,
Content,
ConverseError,
NativeContent,
async_get_chat_session,
)
from .trace import ConversationTraceEventType, async_conversation_trace_append
__all__ = [
"DOMAIN",
"HOME_ASSISTANT_AGENT",
"OLD_HOME_ASSISTANT_AGENT",
"ChatSession",
"AssistantContent",
"ChatLog",
"Content",
"ConversationEntity",
"ConversationEntityFeature",
@ -69,11 +73,13 @@ __all__ = [
"ConversationResult",
"ConversationTraceEventType",
"ConverseError",
"NativeContent",
"SystemContent",
"ToolResultContent",
"UserContent",
"async_conversation_trace_append",
"async_converse",
"async_get_agent_info",
"async_get_chat_session",
"async_get_chat_log",
"async_set_agent",
"async_setup",
"async_unset_agent",

View File

@ -0,0 +1,290 @@
"""Conversation history."""
from __future__ import annotations
from collections.abc import AsyncGenerator, Generator
from contextlib import contextmanager
from dataclasses import dataclass, field, replace
import logging
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import chat_session, intent, llm, template
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from . import trace
from .const import DOMAIN
from .models import ConversationInput, ConversationResult
DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log")
LOGGER = logging.getLogger(__name__)
@contextmanager
def async_get_chat_log(
hass: HomeAssistant,
session: chat_session.ChatSession,
user_input: ConversationInput | None = None,
) -> Generator[ChatLog]:
"""Return chat log for a specific chat session."""
all_history = hass.data.get(DATA_CHAT_HISTORY)
if all_history is None:
all_history = {}
hass.data[DATA_CHAT_HISTORY] = all_history
history = all_history.get(session.conversation_id)
if history:
history = replace(history, content=history.content.copy())
else:
history = ChatLog(hass, session.conversation_id)
@callback
def do_cleanup() -> None:
"""Handle cleanup."""
all_history.pop(session.conversation_id)
session.async_on_cleanup(do_cleanup)
if user_input is not None:
history.async_add_user_content(UserContent(content=user_input.text))
last_message = history.content[-1]
yield history
if history.content[-1] is last_message:
LOGGER.debug(
"History opened but no assistant message was added, ignoring update"
)
return
all_history[session.conversation_id] = history
class ConverseError(HomeAssistantError):
"""Error during initialization of conversation.
Will not be stored in the history.
"""
def __init__(
self, message: str, conversation_id: str, response: intent.IntentResponse
) -> None:
"""Initialize the error."""
super().__init__(message)
self.conversation_id = conversation_id
self.response = response
def as_conversation_result(self) -> ConversationResult:
"""Return the error as a conversation result."""
return ConversationResult(
response=self.response,
conversation_id=self.conversation_id,
)
@dataclass(frozen=True)
class SystemContent:
"""Base class for chat messages."""
role: str = field(init=False, default="system")
content: str
@dataclass(frozen=True)
class UserContent:
"""Assistant content."""
role: str = field(init=False, default="user")
content: str
@dataclass(frozen=True)
class AssistantContent:
"""Assistant content."""
role: str = field(init=False, default="assistant")
agent_id: str
content: str
tool_calls: list[llm.ToolInput] | None = None
@dataclass(frozen=True)
class ToolResultContent:
"""Tool result content."""
role: str = field(init=False, default="tool_result")
agent_id: str
tool_call_id: str
tool_name: str
tool_result: JsonObjectType
Content = SystemContent | UserContent | AssistantContent | ToolResultContent
@dataclass
class ChatLog:
"""Class holding the chat history of a specific conversation."""
hass: HomeAssistant
conversation_id: str
content: list[Content] = field(default_factory=lambda: [SystemContent(content="")])
extra_system_prompt: str | None = None
llm_api: llm.APIInstance | None = None
@callback
def async_add_user_content(self, content: UserContent) -> None:
"""Add user content to the log."""
self.content.append(content)
async def async_add_assistant_content(
self, content: AssistantContent
) -> AsyncGenerator[ToolResultContent]:
"""Add assistant content."""
self.content.append(content)
if content.tool_calls is None:
return
if self.llm_api is None:
raise ValueError("No LLM API configured")
for tool_input in content.tool_calls:
LOGGER.debug(
"Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
)
try:
tool_result = await self.llm_api.async_call_tool(tool_input)
except (HomeAssistantError, vol.Invalid) as e:
tool_result = {"error": type(e).__name__}
if str(e):
tool_result["error_text"] = str(e)
LOGGER.debug("Tool response: %s", tool_result)
response_content = ToolResultContent(
agent_id=content.agent_id,
tool_call_id=tool_input.id,
tool_name=tool_input.tool_name,
tool_result=tool_result,
)
self.content.append(response_content)
yield response_content
async def async_update_llm_data(
self,
conversing_domain: str,
user_input: ConversationInput,
user_llm_hass_api: str | None = None,
user_llm_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_context = llm.LLMContext(
platform=conversing_domain,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=DOMAIN,
device_id=user_input.device_id,
)
llm_api: llm.APIInstance | None = None
if user_llm_hass_api:
try:
llm_api = await llm.async_get_api(
self.hass,
user_llm_hass_api,
llm_context,
)
except HomeAssistantError as err:
LOGGER.error(
"Error getting LLM API %s for %s: %s",
user_llm_hass_api,
conversing_domain,
err,
)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Error preparing LLM API",
)
raise ConverseError(
f"Error getting LLM API {user_llm_hass_api}",
conversation_id=self.conversation_id,
response=intent_response,
) from err
user_name: str | None = None
if (
user_input.context
and user_input.context.user_id
and (
user := await self.hass.auth.async_get_user(user_input.context.user_id)
)
):
user_name = user.name
try:
prompt_parts = [
template.Template(
llm.BASE_PROMPT
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
]
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
if llm_api:
prompt_parts.append(llm_api.api_prompt)
extra_system_prompt = (
# Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt
)
if extra_system_prompt:
prompt_parts.append(extra_system_prompt)
prompt = "\n".join(prompt_parts)
self.llm_api = llm_api
self.extra_system_prompt = extra_system_prompt
self.content[0] = SystemContent(content=prompt)
LOGGER.debug("Prompt: %s", self.content)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
trace.async_conversation_trace_append(
trace.ConversationTraceEventType.AGENT_DETAIL,
{
"messages": self.content,
"tools": self.llm_api.tools if self.llm_api else None,
},
)

View File

@ -42,6 +42,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.helpers import (
area_registry as ar,
chat_session,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@ -54,6 +55,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util.json import JsonObjectType, json_loads_object
from .chat_log import AssistantContent, async_get_chat_log
from .const import (
DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES,
@ -62,7 +64,6 @@ from .const import (
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .session import Content, async_get_chat_session
from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__)
@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
response: intent.IntentResponse | None = None
async with async_get_chat_session(self.hass, user_input) as chat_session:
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
async_get_chat_log(self.hass, session, user_input) as chat_log,
):
# Check if a trigger matched
if trigger_result := await self.async_recognize_sentence_trigger(
user_input
@ -373,16 +379,16 @@ class DefaultAgent(ConversationEntity):
)
speech: str = response.speech.get("plain", {}).get("speech", "")
chat_session.async_add_message(
Content(
role="assistant",
agent_id=user_input.agent_id,
async for _tool_result in chat_log.async_add_assistant_content(
AssistantContent(
agent_id=user_input.agent_id, # type: ignore[arg-type]
content=speech,
)
)
):
pass
return ConversationResult(
response=response, conversation_id=chat_session.conversation_id
response=response, conversation_id=session.conversation_id
)
async def _async_process_intent_result(

View File

@ -1,359 +0,0 @@
"""Conversation history."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field, replace
from datetime import datetime, timedelta
import logging
from typing import Literal
import voluptuous as vol
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
CALLBACK_TYPE,
Event,
HassJob,
HassJobType,
HomeAssistant,
callback,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import intent, llm, template
from homeassistant.helpers.event import async_call_later
from homeassistant.util import dt as dt_util, ulid as ulid_util
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.json import JsonObjectType
from . import trace
from .const import DOMAIN
from .models import ConversationInput, ConversationResult
DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey(
"conversation_chat_session"
)
DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey(
"conversation_chat_session_cleanup"
)
LOGGER = logging.getLogger(__name__)
CONVERSATION_TIMEOUT = timedelta(minutes=5)
class SessionCleanup:
"""Helper to clean up the history."""
unsub: CALLBACK_TYPE | None = None
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the history cleanup."""
self.hass = hass
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop)
self.cleanup_job = HassJob(
self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback
)
@callback
def schedule(self) -> None:
"""Schedule the cleanup."""
if self.unsub:
return
self.unsub = async_call_later(
self.hass,
CONVERSATION_TIMEOUT.total_seconds() + 1,
self.cleanup_job,
)
@callback
def _on_hass_stop(self, event: Event) -> None:
"""Cancel the cleanup on shutdown."""
if self.unsub:
self.unsub()
self.unsub = None
@callback
def _cleanup(self, now: datetime) -> None:
"""Clean up the history and schedule follow-up if necessary."""
self.unsub = None
all_history = self.hass.data[DATA_CHAT_HISTORY]
# We mutate original object because current commands could be
# yielding history based on it.
for conversation_id, history in list(all_history.items()):
if history.last_updated + CONVERSATION_TIMEOUT < now:
del all_history[conversation_id]
# Still conversations left, check again in timeout time.
if all_history:
self.schedule()
@asynccontextmanager
async def async_get_chat_session(
hass: HomeAssistant,
user_input: ConversationInput,
) -> AsyncGenerator[ChatSession]:
"""Return chat session."""
all_history = hass.data.get(DATA_CHAT_HISTORY)
if all_history is None:
all_history = {}
hass.data[DATA_CHAT_HISTORY] = all_history
hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass)
history: ChatSession | None = None
if user_input.conversation_id is None:
conversation_id = ulid_util.ulid_now()
elif history := all_history.get(user_input.conversation_id):
conversation_id = user_input.conversation_id
else:
# Conversation IDs are ULIDs. We generate a new one if not provided.
# If an old OLID is passed in, we will generate a new one to indicate
# a new conversation was started. If the user picks their own, they
# want to track a conversation and we respect it.
try:
ulid_util.ulid_to_bytes(user_input.conversation_id)
conversation_id = ulid_util.ulid_now()
except ValueError:
conversation_id = user_input.conversation_id
if history:
history = replace(history, messages=history.messages.copy())
else:
history = ChatSession(hass, conversation_id, user_input.agent_id)
message: Content = Content(
role="user",
agent_id=user_input.agent_id,
content=user_input.text,
)
history.async_add_message(message)
yield history
if history.messages[-1] is message:
LOGGER.debug(
"History opened but no assistant message was added, ignoring update"
)
return
history.last_updated = dt_util.utcnow()
all_history[conversation_id] = history
hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule()
class ConverseError(HomeAssistantError):
"""Error during initialization of conversation.
Will not be stored in the history.
"""
def __init__(
self, message: str, conversation_id: str, response: intent.IntentResponse
) -> None:
"""Initialize the error."""
super().__init__(message)
self.conversation_id = conversation_id
self.response = response
def as_conversation_result(self) -> ConversationResult:
"""Return the error as a conversation result."""
return ConversationResult(
response=self.response,
conversation_id=self.conversation_id,
)
@dataclass
class Content:
"""Base class for chat messages."""
role: Literal["system", "assistant", "user"]
agent_id: str | None
content: str
@dataclass(frozen=True)
class NativeContent[_NativeT]:
"""Native content."""
role: str = field(init=False, default="native")
agent_id: str
content: _NativeT
@dataclass
class ChatSession[_NativeT]:
"""Class holding all information for a specific conversation."""
hass: HomeAssistant
conversation_id: str
agent_id: str | None
user_name: str | None = None
messages: list[Content | NativeContent[_NativeT]] = field(
default_factory=lambda: [Content(role="system", agent_id=None, content="")]
)
extra_system_prompt: str | None = None
llm_api: llm.APIInstance | None = None
last_updated: datetime = field(default_factory=dt_util.utcnow)
@callback
def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None:
"""Process intent."""
if message.role == "system":
raise ValueError("Cannot add system messages to history")
if message.role != "native" and self.messages[-1].role == message.role:
raise ValueError("Cannot add two assistant or user messages in a row")
self.messages.append(message)
@callback
def async_get_messages(
self, agent_id: str | None = None
) -> list[Content | NativeContent[_NativeT]]:
"""Get messages for a specific agent ID.
This will filter out any native message tied to other agent IDs.
It can still include assistant/user messages generated by other agents.
"""
return [
message
for message in self.messages
if message.role != "native" or message.agent_id == agent_id
]
async def async_update_llm_data(
self,
conversing_domain: str,
user_input: ConversationInput,
user_llm_hass_api: str | None = None,
user_llm_prompt: str | None = None,
) -> None:
"""Set the LLM system prompt."""
llm_context = llm.LLMContext(
platform=conversing_domain,
context=user_input.context,
user_prompt=user_input.text,
language=user_input.language,
assistant=DOMAIN,
device_id=user_input.device_id,
)
llm_api: llm.APIInstance | None = None
if user_llm_hass_api:
try:
llm_api = await llm.async_get_api(
self.hass,
user_llm_hass_api,
llm_context,
)
except HomeAssistantError as err:
LOGGER.error(
"Error getting LLM API %s for %s: %s",
user_llm_hass_api,
conversing_domain,
err,
)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Error preparing LLM API",
)
raise ConverseError(
f"Error getting LLM API {user_llm_hass_api}",
conversation_id=self.conversation_id,
response=intent_response,
) from err
user_name: str | None = None
if (
user_input.context
and user_input.context.user_id
and (
user := await self.hass.auth.async_get_user(user_input.context.user_id)
)
):
user_name = user.name
try:
prompt_parts = [
template.Template(
llm.BASE_PROMPT
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
self.hass,
).async_render(
{
"ha_name": self.hass.config.location_name,
"user_name": user_name,
"llm_context": llm_context,
},
parse_result=False,
)
]
except TemplateError as err:
LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem with my template",
)
raise ConverseError(
"Error rendering prompt",
conversation_id=self.conversation_id,
response=intent_response,
) from err
if llm_api:
prompt_parts.append(llm_api.api_prompt)
extra_system_prompt = (
# Take new system prompt if one was given
user_input.extra_system_prompt or self.extra_system_prompt
)
if extra_system_prompt:
prompt_parts.append(extra_system_prompt)
prompt = "\n".join(prompt_parts)
self.llm_api = llm_api
self.user_name = user_name
self.extra_system_prompt = extra_system_prompt
self.messages[0] = Content(
role="system",
agent_id=user_input.agent_id,
content=prompt,
)
LOGGER.debug("Prompt: %s", self.messages)
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
trace.async_conversation_trace_append(
trace.ConversationTraceEventType.AGENT_DETAIL,
{
"messages": self.messages,
"tools": self.llm_api.tools if self.llm_api else None,
},
)
async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType:
"""Invoke LLM tool for the configured LLM API."""
if not self.llm_api:
raise ValueError("No LLM API configured")
LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args)
try:
tool_response = await self.llm_api.async_call_tool(tool_input)
except (HomeAssistantError, vol.Invalid) as e:
tool_response = {"error": type(e).__name__}
if str(e):
tool_response["error_text"] = str(e)
LOGGER.debug("Tool response: %s", tool_response)
return tool_response

View File

@ -14,7 +14,7 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.0.2",
"aiodhcpwatcher==1.0.3",
"aiodiscover==2.1.0",
"cached-ipaddress==0.8.0"
]

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b1"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"]
}

View File

@ -2,6 +2,7 @@
from typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
@ -39,17 +40,23 @@ async def async_setup_entry(
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None:
"""Set up the light entities for a device."""
device = coordinator.hub.devices[device_address]
def async_setup_device_entities(
device_address: str | dict[str, EheimDigitalDevice],
) -> None:
"""Set up the climate entities for one or multiple devices."""
entities: list[EheimDigitalHeaterClimate] = []
if isinstance(device_address, str):
device_address = {device_address: coordinator.hub.devices[device_address]}
for device in device_address.values():
if isinstance(device, EheimDigitalHeater):
entities.append(EheimDigitalHeaterClimate(coordinator, device))
coordinator.known_devices.add(device.mac_address)
if isinstance(device, EheimDigitalHeater):
async_add_entities([EheimDigitalHeaterClimate(coordinator, device)])
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
for device_address in entry.runtime_data.hub.devices:
await async_setup_device_entities(device_address)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
@ -69,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_mode = PRESET_NONE
_attr_translation_key = "heater"
_attr_name = None
def __init__(
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater

View File

@ -2,8 +2,7 @@
from __future__ import annotations
from collections.abc import Callable, Coroutine
from typing import Any
from collections.abc import Callable
from aiohttp import ClientError
from eheimdigital.device import EheimDigitalDevice
@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
from .const import DOMAIN, LOGGER
type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]]
type AsyncSetupDeviceEntitiesCallback = Callable[
[str | dict[str, EheimDigitalDevice]], None
]
class EheimDigitalUpdateCoordinator(
@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator(
if device_address not in self.known_devices:
for platform_callback in self.platform_callbacks:
await platform_callback(device_address)
platform_callback(device_address)
async def _async_receive_callback(self) -> None:
self.async_set_updated_data(self.hub.devices)

View File

@ -3,6 +3,7 @@
from typing import Any
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import EheimDigitalClientError, LightMode
from homeassistant.components.light import (
@ -37,24 +38,28 @@ async def async_setup_entry(
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None:
"""Set up the light entities for a device."""
device = coordinator.hub.devices[device_address]
def async_setup_device_entities(
device_address: str | dict[str, EheimDigitalDevice],
) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalClassicLEDControlLight] = []
if isinstance(device_address, str):
device_address = {device_address: coordinator.hub.devices[device_address]}
for device in device_address.values():
if isinstance(device, EheimDigitalClassicLEDControl):
for channel in range(2):
if len(device.tankconfig[channel]) > 0:
entities.append(
EheimDigitalClassicLEDControlLight(
coordinator, device, channel
)
)
coordinator.known_devices.add(device.mac_address)
if isinstance(device, EheimDigitalClassicLEDControl):
for channel in range(2):
if len(device.tankconfig[channel]) > 0:
entities.append(
EheimDigitalClassicLEDControlLight(coordinator, device, channel)
)
coordinator.known_devices.add(device.mac_address)
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
for device_address in entry.runtime_data.hub.devices:
await async_setup_device_entities(device_address)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalClassicLEDControlLight(

View File

@ -3,7 +3,7 @@
"config": {
"step": {
"user": {
"title": "Searching for Energenie-Power-Sockets Devices.",
"title": "Searching for Energenie Power Sockets devices",
"description": "Choose a discovered device.",
"data": {
"device": "[%key:common::config_flow::data::device%]"
@ -13,7 +13,7 @@
"abort": {
"usb_error": "Couldn't access USB devices!",
"no_device": "Unable to discover any (new) supported device.",
"device_not_found": "No device was found for the given id.",
"device_not_found": "No device was found for the given ID.",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},

View File

@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"]
}

View File

@ -28,6 +28,7 @@ def async_connect_scanner(
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available)
@ -45,6 +46,7 @@ def async_connect_scanner(
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],

View File

@ -425,7 +425,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(hass, entry_data, cli, device_info)
async_connect_scanner(
hass, entry_data, cli, device_info, self.device_id
)
)
else:
bluetooth.async_remove_scanner(hass, device_info.mac_address)
@ -571,7 +573,9 @@ def _async_setup_device_registry(
configuration_url = None
if device_info.webserver_port > 0:
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
entry_host = entry.data["host"]
host = f"[{entry_host}]" if ":" in entry_host else entry_host
configuration_url = f"http://{host}:{device_info.webserver_port}"
elif (
(dashboard := async_get_dashboard(hass))
and dashboard.data

View File

@ -18,7 +18,7 @@
"requirements": [
"aioesphomeapi==29.0.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.2.0"
"bleak-esphome==2.7.0"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250130.0"]
"requirements": ["home-assistant-frontend==20250131.0"]
}

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity):
value, extra_state_attributes = self.entity_description.state_fn(value)
if self.entity_description.round_state_value:
if TYPE_CHECKING:
assert isinstance(value, int)
value = round_storage(value)
self._attr_native_value = value

View File

@ -28,14 +28,14 @@
"user": {
"description": "Enter the settings to connect to the camera.",
"data": {
"still_image_url": "Still Image URL (e.g. http://...)",
"stream_source": "Stream Source URL (e.g. rtsp://...)",
"still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)",
"rtsp_transport": "RTSP transport protocol",
"authentication": "Authentication",
"limit_refetch_to_url_change": "Limit refetch to url change",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"framerate": "Frame Rate (Hz)",
"framerate": "Frame rate (Hz)",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},

View File

@ -11,7 +11,7 @@ from aiohttp import ClientSession, ClientTimeout, StreamReader
from aiohttp.client_exceptions import ClientError, ClientResponseError
from google_drive_api.api import AbstractAuth, GoogleDriveApi
from homeassistant.components.backup import AgentBackup
from homeassistant.components.backup import AgentBackup, suggested_filename
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.exceptions import (
@ -132,7 +132,7 @@ class DriveClient:
"""Upload a backup."""
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
backup_metadata = {
"name": f"{backup.name} {backup.date}.tar",
"name": suggested_filename(backup),
"description": json.dumps(backup.as_dict()),
"parents": [folder_id],
"properties": {

View File

@ -2,6 +2,7 @@
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
"redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass),
}

View File

@ -35,6 +35,6 @@
}
},
"application_credentials": {
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
"description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*."
}
}

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import codecs
from collections.abc import Callable
from typing import Any, Literal
from typing import Any, Literal, cast
from google.api_core.exceptions import GoogleAPIError
import google.generativeai as genai
@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any:
return value
def _chat_message_convert(
message: conversation.Content | conversation.NativeContent[genai_types.ContentDict],
) -> genai_types.ContentDict:
"""Convert any native chat message for this agent to the native format."""
if message.role == "native":
return message.content
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> protos.Content:
"""Create a Google tool response content."""
return protos.Content(
parts=[
protos.Part(
function_response=protos.FunctionResponse(
name=tool_result.tool_name, response=tool_result.tool_result
)
)
for tool_result in content
]
)
role = "model" if message.role == "assistant" else message.role
return {"role": role, "parts": message.content}
def _convert_content(
content: conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent,
) -> genai_types.ContentDict:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
role = "model" if content.role == "assistant" else content.role
return {"role": role, "parts": content.content}
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts = []
if content.content:
parts.append(protos.Part(text=content.content))
if content.tool_calls:
parts.extend(
[
protos.Part(
function_call=protos.FunctionCall(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
)
for tool_call in content.tool_calls
]
)
return protos.Content({"role": "model", "parts": parts})
class GoogleGenerativeAIConversationEntity(
@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
async with conversation.async_get_chat_session(
self.hass, user_input
) as session:
return await self._async_handle_message(user_input, session)
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
):
return await self._async_handle_message(user_input, chat_log)
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
session: conversation.ChatSession[genai_types.ContentDict],
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Call the API."""
@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity(
options = self.entry.options
try:
await session.async_update_llm_data(
await chat_log.async_update_llm_data(
DOMAIN,
user_input,
options.get(CONF_LLM_HASS_API),
@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity(
return err.as_conversation_result()
tools: list[dict[str, Any]] | None = None
if session.llm_api:
if chat_log.llm_api:
tools = [
_format_tool(tool, session.llm_api.custom_serializer)
for tool in session.llm_api.tools
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
@ -249,9 +290,36 @@ class GoogleGenerativeAIConversationEntity(
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
)
prompt, *messages = [
_chat_message_convert(message) for message in session.async_get_messages()
]
prompt = chat_log.content[0].content # type: ignore[union-attr]
messages: list[genai_types.ContentDict] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:]:
if chat_content.role == "tool_result":
# mypy doesn't like picking a type based on checking shared property 'role'
tool_results.append(cast(conversation.ToolResultContent, chat_content))
continue
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(
_convert_content(
cast(
conversation.UserContent
| conversation.SystemContent
| conversation.AssistantContent,
chat_content,
)
)
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
model = genai.GenerativeModel(
model_name=model_name,
generation_config={
@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity(
),
},
tools=tools or None,
system_instruction=prompt["parts"] if supports_system_instruction else None,
system_instruction=prompt if supports_system_instruction else None,
)
if not supports_system_instruction:
messages = [
{"role": "user", "parts": prompt["parts"]},
{"role": "user", "parts": prompt},
{"role": "model", "parts": "Ok"},
*messages,
]
@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity(
content = " ".join(
[part.text.strip() for part in chat_response.parts if part.text]
)
if content:
session.async_add_message(
conversation.Content(
role="assistant",
agent_id=user_input.agent_id,
content=content,
)
)
function_calls = [
part.function_call for part in chat_response.parts if part.function_call
]
if not function_calls or not session.llm_api:
break
tool_responses = []
for function_call in function_calls:
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
tool_calls = []
for part in chat_response.parts:
if not part.function_call:
continue
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
tool_name = tool_call["name"]
tool_args = _escape_decode(tool_call["args"])
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
function_response = await session.async_call_tool(tool_input)
tool_responses.append(
protos.Part(
function_response=protos.FunctionResponse(
name=tool_name, response=function_response
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
chat_request = _create_google_tool_response_content(
[
tool_response
async for tool_response in chat_log.async_add_assistant_content(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=content,
tool_calls=tool_calls or None,
)
)
)
chat_request = protos.Content(parts=tool_responses)
session.async_add_message(
conversation.NativeContent(
agent_id=user_input.agent_id,
content=chat_request,
)
]
)
if not tool_calls:
break
response = intent.IntentResponse(language=user_input.language)
response.async_set_speech(
" ".join([part.text.strip() for part in chat_response.parts if part.text])
)
return conversation.ConversationResult(
response=response, conversation_id=session.conversation_id
response=response, conversation_id=chat_log.conversation_id
)
async def _async_entry_update_listener(

View File

@ -78,7 +78,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
title=title, data={CONF_DEVICE_TYPE: device.device_type}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.3"]
"requirements": ["habiticalib==0.3.4"]
}

View File

@ -6,7 +6,7 @@ import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import logging
import os
from pathlib import Path
from pathlib import Path, PurePath
from typing import Any, cast
from uuid import UUID
@ -33,16 +33,20 @@ from homeassistant.components.backup import (
Folder,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupState,
WrittenBackup,
async_get_manager as async_get_backup_manager,
suggested_filename as suggested_backup_filename,
suggested_filename_from_name_date,
)
from homeassistant.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
@ -51,6 +55,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup"
LOCATION_LOCAL = ".local"
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
# Set on backups automatically created when updating an addon
TAG_ADDON_UPDATE = "supervisor.addon_update"
_LOGGER = logging.getLogger(__name__)
@ -113,12 +119,15 @@ def _backup_details_to_agent_backup(
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
for addon in details.addons
]
extra_metadata = details.extra or {}
location = location or LOCATION_LOCAL
return AgentBackup(
addons=addons,
backup_id=details.slug,
database_included=database_included,
date=details.date.isoformat(),
date=extra_metadata.get(
"supervisor.backup_request_date", details.date.isoformat()
),
extra_metadata=details.extra or {},
folders=[Folder(folder) for folder in details.folders],
homeassistant_included=homeassistant_included,
@ -174,7 +183,8 @@ class SupervisorBackupAgent(BackupAgent):
return
stream = await open_stream()
upload_options = supervisor_backups.UploadBackupOptions(
location={self.location}
location={self.location},
filename=PurePath(suggested_backup_filename(backup)),
)
await self._client.backups.upload_backup(
stream,
@ -301,6 +311,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
locations = []
locations = locations or [LOCATION_CLOUD_BACKUP]
date = dt_util.now().isoformat()
extra_metadata = extra_metadata | {"supervisor.backup_request_date": date}
filename = suggested_filename_from_name_date(backup_name, date)
try:
backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions(
@ -314,6 +327,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
homeassistant_exclude_database=not include_database,
background=True,
extra=extra_metadata,
filename=PurePath(filename),
)
)
except SupervisorError as err:
@ -503,17 +517,22 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
raise HomeAssistantError(message) from err
restore_complete = asyncio.Event()
restore_errors: list[dict[str, str]] = []
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup restore progress."""
if data.get("done") is True:
restore_complete.set()
restore_errors.extend(data.get("errors", []))
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
try:
await self._get_job_state(job.job_id, on_job_progress)
await restore_complete.wait()
if restore_errors:
# We should add more specific error handling here in the future
raise BackupReaderWriterError(f"Restore failed: {restore_errors}")
finally:
unsub()
@ -540,11 +559,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
)
return
on_progress(
RestoreBackupEvent(
reason="", stage=None, state=RestoreBackupState.COMPLETED
restore_errors = data.get("errors", [])
if restore_errors:
_LOGGER.warning("Restore backup failed: %s", restore_errors)
# We should add more specific error handling here in the future
on_progress(
RestoreBackupEvent(
reason="unknown_error",
stage=None,
state=RestoreBackupState.FAILED,
)
)
else:
on_progress(
RestoreBackupEvent(
reason="", stage=None, state=RestoreBackupState.COMPLETED
)
)
)
on_progress(IdleEvent())
unsub()
@ -614,10 +645,20 @@ async def backup_addon_before_update(
else:
password = None
def addon_update_backup_filter(
backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]:
"""Return addon update backups."""
return {
backup_id: backup
for backup_id, backup in backups.items()
if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
}
try:
await backup_manager.async_create_backup(
agent_ids=[await _default_agent(client)],
extra_metadata={"supervisor.addon_update": addon},
extra_metadata={TAG_ADDON_UPDATE: addon},
include_addons=[addon],
include_all_addons=False,
include_database=False,
@ -628,6 +669,14 @@ async def backup_addon_before_update(
)
except BackupManagerError as err:
raise HomeAssistantError(f"Error creating backup: {err}") from err
else:
try:
await backup_manager.async_delete_filtered_backups(
include_filter=addon_update_backup_filter,
delete_filter=lambda backups: backups,
)
except BackupManagerError as err:
raise HomeAssistantError(f"Error deleting old backups: {err}") from err
async def backup_core_before_update(hass: HomeAssistant) -> None:

View File

@ -6,7 +6,7 @@ import logging
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import CommandKey, Option, OptionKey
from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
import voluptuous as vol
@ -50,7 +50,10 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
SERVICE_SETTING_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_KEY): str,
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(SettingKey),
vol.NotIn([SettingKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
}
)
@ -58,7 +61,10 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
SERVICE_OPTION_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_KEY): str,
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(OptionKey),
vol.NotIn([OptionKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
vol.Optional(ATTR_UNIT): str,
}
@ -67,14 +73,23 @@ SERVICE_OPTION_SCHEMA = vol.Schema(
SERVICE_PROGRAM_SCHEMA = vol.Any(
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_PROGRAM): str,
vol.Required(ATTR_KEY): str,
vol.Required(ATTR_PROGRAM): vol.All(
vol.Coerce(ProgramKey),
vol.NotIn([ProgramKey.UNKNOWN]),
),
vol.Required(ATTR_KEY): vol.All(
vol.Coerce(OptionKey),
vol.NotIn([OptionKey.UNKNOWN]),
),
vol.Required(ATTR_VALUE): vol.Any(int, str),
vol.Optional(ATTR_UNIT): str,
},
{
vol.Required(ATTR_DEVICE_ID): str,
vol.Required(ATTR_PROGRAM): str,
vol.Required(ATTR_PROGRAM): vol.All(
vol.Coerce(ProgramKey),
vol.NotIn([ProgramKey.UNKNOWN]),
),
},
)
@ -141,7 +156,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
options = (
[
Option(
OptionKey(option_key),
option_key,
call.data[ATTR_VALUE],
unit=call.data.get(ATTR_UNIT),
)
@ -178,14 +193,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if active:
await client.set_active_program_option(
ha_id,
option_key=OptionKey(option_key),
option_key=option_key,
value=value,
unit=unit,
)
else:
await client.set_selected_program_option(
ha_id,
option_key=OptionKey(option_key),
option_key=option_key,
value=value,
unit=unit,
)

View File

@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .common import setup_home_connect_entry
from .const import (
BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED,
@ -113,24 +114,33 @@ BINARY_SENSORS = (
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
for description in BINARY_SENSORS
if description.key in appliance.status
)
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect binary sensor."""
entities: list[BinarySensorEntity] = []
for appliance in entry.runtime_data.data.values():
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
for description in BINARY_SENSORS
if description.key in appliance.status
)
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
async_add_entities(entities)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):

View File

@ -0,0 +1,99 @@
"""Common callbacks for all Home Connect platforms."""
from collections.abc import Callable
from functools import partial
from typing import cast
from aiohomeconnect.model import EventKey
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
def _handle_paired_or_connected_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Handle a new paired appliance or an appliance that has been connected.
This function is used to handle connected events also, because some appliances
don't report any data while they are off because they disconnect themselves
when they are turned off, so we need to check if the entities have been added
already or it is the first time we see them when the appliance is connected.
"""
entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values():
entities_to_add = [
entity
for entity in get_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids
]
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in entities_to_add
}
)
entities.extend(entities_to_add)
async_add_entities(entities)
def _handle_depaired_appliance(
entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str],
) -> None:
"""Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data:
known_entity_unique_ids.pop(entity_unique_id, None)
def setup_home_connect_entry(
entry: HomeConnectConfigEntry,
get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
],
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the callbacks for paired and depaired appliances."""
known_entity_unique_ids: dict[str, str] = {}
entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values():
entities_to_add = get_entities_for_appliance(entry, appliance)
known_entity_unique_ids.update(
{
cast(str, entity.unique_id): appliance.info.ha_id
for entity in entities_to_add
}
)
entities.extend(entities_to_add)
async_add_entities(entities)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
partial(
_handle_paired_or_connected_appliance,
entry,
known_entity_unique_ids,
get_entities_for_appliance,
async_add_entities,
),
(
EventKey.BSH_COMMON_APPLIANCE_PAIRED,
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
),
)
)
entry.async_on_unload(
entry.runtime_data.async_add_special_listener(
partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
)
)

View File

@ -3,7 +3,7 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from dataclasses import dataclass
import logging
from typing import Any
@ -25,11 +25,12 @@ from aiohomeconnect.model.error import (
HomeConnectError,
HomeConnectRequestError,
)
from aiohomeconnect.model.program import EnumerateAvailableProgram
from aiohomeconnect.model.program import EnumerateProgram
from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
@ -46,9 +47,9 @@ EVENT_STREAM_RECONNECT_DELAY = 30
class HomeConnectApplianceData:
"""Class to hold Home Connect appliance data."""
events: dict[EventKey, Event] = field(default_factory=dict)
events: dict[EventKey, Event]
info: HomeAppliance
programs: list[EnumerateAvailableProgram] = field(default_factory=list)
programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status]
@ -83,6 +84,10 @@ class HomeConnectCoordinator(
name=config_entry.entry_id,
)
self.client = client
self._special_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@ -107,6 +112,28 @@ class HomeConnectCoordinator(
return remove_listener_and_invalidate_context_listeners
@callback
def async_add_special_listener(
self,
update_callback: CALLBACK_TYPE,
context: tuple[EventKey, ...],
) -> Callable[[], None]:
"""Listen for special data updates.
These listeners will not be called on refresh.
"""
@callback
def remove_listener() -> None:
"""Remove update listener."""
self._special_listeners.pop(remove_listener)
if not self._special_listeners:
self._unschedule_refresh()
self._special_listeners[remove_listener] = (update_callback, context)
return remove_listener
@callback
def start_event_listener(self) -> None:
"""Start event listener."""
@ -121,9 +148,10 @@ class HomeConnectCoordinator(
while True:
try:
async for event_message in self.client.stream_all_events():
event_message_ha_id = event_message.ha_id
match event_message.type:
case EventType.STATUS:
statuses = self.data[event_message.ha_id].status
statuses = self.data[event_message_ha_id].status
for event in event_message.data.items:
status_key = StatusKey(event.key)
if status_key in statuses:
@ -134,10 +162,11 @@ class HomeConnectCoordinator(
raw_key=status_key.value,
value=event.value,
)
self._call_event_listener(event_message)
case EventType.NOTIFY:
settings = self.data[event_message.ha_id].settings
events = self.data[event_message.ha_id].events
settings = self.data[event_message_ha_id].settings
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
if event.key in SettingKey:
setting_key = SettingKey(event.key)
@ -151,13 +180,56 @@ class HomeConnectCoordinator(
)
else:
events[event.key] = event
self._call_event_listener(event_message)
case EventType.EVENT:
events = self.data[event_message.ha_id].events
events = self.data[event_message_ha_id].events
for event in event_message.data.items:
events[event.key] = event
self._call_event_listener(event_message)
self._call_event_listener(event_message)
case EventType.CONNECTED | EventType.PAIRED:
appliance_info = await self.client.get_specific_appliance(
event_message_ha_id
)
appliance_data = await self._get_appliance_data(
appliance_info, self.data.get(appliance_info.ha_id)
)
if event_message_ha_id in self.data:
self.data[event_message_ha_id].update(appliance_data)
else:
self.data[event_message_ha_id] = appliance_data
for listener, context in list(
self._special_listeners.values()
) + list(self._listeners.values()):
assert isinstance(context, tuple)
if (
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
not in context
):
listener()
case EventType.DISCONNECTED:
self.data[event_message_ha_id].info.connected = False
self._call_all_event_listeners_for_appliance(
event_message_ha_id
)
case EventType.DEPAIRED:
device = self.device_registry.async_get_device(
identifiers={(DOMAIN, event_message_ha_id)}
)
if device:
self.device_registry.async_update_device(
device_id=device.id,
remove_config_entry_id=self.config_entry.entry_id,
)
self.data.pop(event_message_ha_id, None)
for listener, context in self._special_listeners.values():
assert isinstance(context, tuple)
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
listener()
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
_LOGGER.debug(
@ -186,6 +258,12 @@ class HomeConnectCoordinator(
):
listener()
@callback
def _call_all_event_listeners_for_appliance(self, ha_id: str):
for listener, context in self._listeners.values():
if isinstance(context, tuple) and context[0] == ha_id:
listener()
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
"""Fetch data from Home Connect."""
try:
@ -197,62 +275,101 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
appliances_data = self.data or {}
for appliance in appliances.homeappliances:
try:
settings = {
setting.key: setting
for setting in (
await self.client.get_settings(appliance.ha_id)
).settings
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching settings for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
settings = {}
try:
status = {
status.key: status
for status in (await self.client.get_status(appliance.ha_id)).status
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching status for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
status = {}
appliance_data = HomeConnectApplianceData(
info=appliance, settings=settings, status=status
return {
appliance.ha_id: await self._get_appliance_data(
appliance, self.data.get(appliance.ha_id) if self.data else None
)
if appliance.ha_id in appliances_data:
appliances_data[appliance.ha_id].update(appliance_data)
appliance_data = appliances_data[appliance.ha_id]
for appliance in appliances.homeappliances
}
async def _get_appliance_data(
self,
appliance: HomeAppliance,
appliance_data_to_update: HomeConnectApplianceData | None = None,
) -> HomeConnectApplianceData:
"""Get appliance data."""
self.device_registry.async_get_or_create(
config_entry_id=self.config_entry.entry_id,
identifiers={(DOMAIN, appliance.ha_id)},
manufacturer=appliance.brand,
name=appliance.name,
model=appliance.vib,
)
try:
settings = {
setting.key: setting
for setting in (
await self.client.get_settings(appliance.ha_id)
).settings
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching settings for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
settings = {}
try:
status = {
status.key: status
for status in (await self.client.get_status(appliance.ha_id)).status
}
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching status for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
status = {}
programs = []
events = {}
if appliance.type in APPLIANCES_WITH_PROGRAMS:
try:
all_programs = await self.client.get_all_programs(appliance.ha_id)
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching programs for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
else:
appliances_data[appliance.ha_id] = appliance_data
if (
appliance.type in APPLIANCES_WITH_PROGRAMS
and not appliance_data.programs
):
try:
appliance_data.programs.extend(
(
await self.client.get_available_programs(appliance.ha_id)
).programs
)
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching programs for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
return appliances_data
programs.extend(all_programs.programs)
for program, event_key in (
(
all_programs.active,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
(
all_programs.selected,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
):
if program and program.key:
events[event_key] = Event(
event_key,
event_key.value,
0,
"",
"",
program.key,
)
appliance_data = HomeConnectApplianceData(
events=events,
info=appliance,
programs=programs,
settings=settings,
status=status,
)
if appliance_data_to_update:
appliance_data_to_update.update(appliance_data)
appliance_data = appliance_data_to_update
return appliance_data

View File

@ -35,9 +35,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.info.ha_id)},
manufacturer=appliance.info.brand,
model=appliance.info.vib,
name=appliance.info.name,
)
self.update_native_value()
@ -56,3 +53,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def bsh_key(self) -> str:
"""Return the BSH key."""
return self.entity_description.key
@property
def available(self) -> bool:
"""Return True if entity is available."""
return (
self.appliance.info.connected and self._attr_available and super().available
)

View File

@ -20,6 +20,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util
from .common import setup_home_connect_entry
from .const import (
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
DOMAIN,
@ -78,20 +79,28 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectLight(entry.runtime_data, appliance, description)
for description in LIGHTS
if description.key in appliance.settings
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect light."""
async_add_entities(
[
HomeConnectLight(entry.runtime_data, appliance, description)
for description in LIGHTS
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)

View File

@ -1,11 +1,11 @@
{
"domain": "home_connect",
"name": "Home Connect",
"codeowners": ["@DavidMStraub", "@Diegorro98"],
"codeowners": ["@DavidMStraub", "@Diegorro98", "@MartinHjelmare"],
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["homeconnect"],
"requirements": ["aiohomeconnect==0.12.1"]
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.12.3"]
}

View File

@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING,
@ -22,7 +23,7 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .coordinator import HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@ -78,19 +79,28 @@ NUMBERS = (
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
for description in NUMBERS
if description.key in appliance.settings
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect number."""
async_add_entities(
[
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
for description in NUMBERS
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)

View File

@ -1,15 +1,20 @@
"""Provides a select platform for Home Connect."""
from typing import cast
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import EventKey, ProgramKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import Execution
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import setup_home_connect_entry
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
from .coordinator import (
HomeConnectApplianceData,
@ -29,41 +34,80 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
}
@dataclass(frozen=True, kw_only=True)
class HomeConnectProgramSelectEntityDescription(
SelectEntityDescription,
):
"""Entity Description class for select entities for programs."""
allowed_executions: tuple[Execution, ...]
set_program_fn: Callable[
[HomeConnectClient, str, ProgramKey], Coroutine[Any, Any, None]
]
error_translation_key: str
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
SelectEntityDescription(
HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
translation_key="active_program",
allowed_executions=(Execution.SELECT_AND_START, Execution.START_ONLY),
set_program_fn=lambda client, ha_id, program_key: client.start_program(
ha_id, program_key=program_key
),
error_translation_key="start_program",
),
SelectEntityDescription(
HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
translation_key="selected_program",
allowed_executions=(Execution.SELECT_AND_START, Execution.SELECT_ONLY),
set_program_fn=lambda client, ha_id, program_key: client.set_selected_program(
ha_id, program_key=program_key
),
error_translation_key="select_program",
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return (
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
else []
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect select entities."""
async_add_entities(
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for appliance in entry.runtime_data.data.values()
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Select class for Home Connect programs."""
entity_description: HomeConnectProgramSelectEntityDescription
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: SelectEntityDescription,
desc: HomeConnectProgramSelectEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
@ -75,9 +119,11 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
for program in appliance.programs
if program.key != ProgramKey.UNKNOWN
and (
program.constraints is None
or program.constraints.execution in desc.allowed_executions
)
]
self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM
self._attr_current_option = None
def update_native_value(self) -> None:
"""Set the program value."""
@ -92,22 +138,15 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Select new program."""
program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
try:
if self.start_on_select:
await self.coordinator.client.start_program(
self.appliance.info.ha_id, program_key=program_key
)
else:
await self.coordinator.client.set_selected_program(
self.appliance.info.ha_id, program_key=program_key
)
await self.entity_description.set_program_fn(
self.coordinator.client,
self.appliance.info.ha_id,
program_key,
)
except HomeConnectError as err:
if self.start_on_select:
translation_key = "start_program"
else:
translation_key = "select_program"
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=translation_key,
translation_key=self.entity_description.error_translation_key,
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,

View File

@ -17,13 +17,14 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from .common import setup_home_connect_entry
from .const import (
APPLIANCES_WITH_PROGRAMS,
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
)
from .coordinator import HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
EVENT_OPTIONS = ["confirmed", "off", "present"]
@ -243,37 +244,42 @@ EVENT_SENSORS = (
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
*[
HomeConnectEventSensor(entry.runtime_data, appliance, description)
for description in EVENT_SENSORS
if description.appliance_types
and appliance.info.type in description.appliance_types
],
*[
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
for desc in BSH_PROGRAM_SENSORS
if desc.appliance_types and appliance.info.type in desc.appliance_types
],
*[
HomeConnectSensor(entry.runtime_data, appliance, description)
for description in SENSORS
if description.key in appliance.status
],
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect sensor."""
entities: list[SensorEntity] = []
for appliance in entry.runtime_data.data.values():
entities.extend(
HomeConnectEventSensor(
entry.runtime_data,
appliance,
description,
)
for description in EVENT_SENSORS
if description.appliance_types
and appliance.info.type in description.appliance_types
)
entities.extend(
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
for desc in BSH_PROGRAM_SENSORS
if desc.appliance_types and appliance.info.type in desc.appliance_types
)
entities.extend(
HomeConnectSensor(entry.runtime_data, appliance, description)
for description in SENSORS
if description.key in appliance.status
)
async_add_entities(entities)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectSensor(HomeConnectEntity, SensorEntity):

View File

@ -5,7 +5,7 @@ from typing import Any, cast
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import EnumerateAvailableProgram
from aiohomeconnect.model.program import EnumerateProgram
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.script import scripts_with_entity
@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
)
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .common import setup_home_connect_entry
from .const import (
BSH_POWER_OFF,
BSH_POWER_ON,
@ -100,33 +101,43 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
entities.extend(
HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
for program in appliance.programs
if program.key != ProgramKey.UNKNOWN
)
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
entities.append(
HomeConnectPowerSwitch(
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
)
)
entities.extend(
HomeConnectSwitch(entry.runtime_data, appliance, description)
for description in SWITCHES
if description.key in appliance.settings
)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
entities: list[SwitchEntity] = []
for appliance in entry.runtime_data.data.values():
entities.extend(
HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
for program in appliance.programs
if program.key != ProgramKey.UNKNOWN
)
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
entities.append(
HomeConnectPowerSwitch(
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
)
)
entities.extend(
HomeConnectSwitch(entry.runtime_data, appliance, description)
for description in SWITCHES
if description.key in appliance.settings
)
async_add_entities(entities)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
@ -184,7 +195,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
program: EnumerateAvailableProgram,
program: EnumerateProgram,
) -> None:
"""Initialize the entity."""
desc = " ".join(["Program", program.key.split(".")[-1]])
@ -192,6 +203,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
desc = " ".join(
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
)
self.program = program
super().__init__(
coordinator,
appliance,
@ -200,7 +212,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self._attr_name = f"{appliance.info.name} {desc}"
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
self._attr_has_entity_name = False
self.program = program
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""

View File

@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING,
@ -18,7 +19,7 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .coordinator import HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
@ -30,20 +31,28 @@ TIME_ENTITIES = (
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
for description in TIME_ENTITIES
if description.key in appliance.settings
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
async_add_entities(
[
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
for description in TIME_ENTITIES
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)

View File

@ -1,6 +1,7 @@
"""Constants for the homee integration."""
from homeassistant.const import (
DEGREE,
LIGHT_LUX,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
@ -32,6 +33,7 @@ HOMEE_UNIT_TO_HA_UNIT = {
"W": UnitOfPower.WATT,
"m/s": UnitOfSpeed.METERS_PER_SECOND,
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
"°": DEGREE,
"°F": UnitOfTemperature.FAHRENHEIT,
"°C": UnitOfTemperature.CELSIUS,
"K": UnitOfTemperature.KELVIN,
@ -51,7 +53,7 @@ OPEN_CLOSE_MAP_REVERSED = {
0.0: "closed",
1.0: "open",
2.0: "partial",
3.0: "cosing",
3.0: "closing",
4.0: "opening",
}
WINDOW_MAP = {

View File

@ -68,6 +68,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
"""Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
@ -93,6 +94,7 @@ def is_cover_node(node: HomeeNode) -> bool:
return node.profile in [
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
NodeProfile.ENTRANCE_GATE_OPERATOR,
NodeProfile.GARAGE_DOOR_OPERATOR,
NodeProfile.SHUTTER_POSITION_SWITCH,
]

View File

@ -2,7 +2,9 @@
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
from pyHomee.model import HomeeAttribute, HomeeNode
from websockets.exceptions import ConnectionClosed
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
@ -137,7 +139,13 @@ class HomeeNodeEntity(Entity):
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
"""Set an attribute value on the homee node."""
homee = self._entry.runtime_data
await homee.set_value(attribute.node_id, attribute.id, value)
try:
await homee.set_value(attribute.node_id, attribute.id, value)
except ConnectionClosed as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_closed",
) from exception
def _on_node_updated(self, node: HomeeNode) -> None:
self.schedule_update_ha_state()

View File

@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
key="total_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
key="total_power",
@ -252,7 +253,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
],
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="node_sensor_state",
translation_key="node_state",
value_fn=lambda node: get_name_for_enum(NodeState, node.state),
),
)

View File

@ -67,7 +67,23 @@
"name": "Link quality"
},
"node_state": {
"name": "Node state"
"name": "Node state",
"state": {
"available": "Available",
"unavailable": "Unavailable",
"update_in_progress": "Update in progress",
"waiting_for_attributes": "Waiting for attributes",
"initializing": "Initializing",
"user_interaction_required": "User interaction required",
"password_required": "Password required",
"host_unavailable": "Host unavailable",
"delete_in_progress": "Delete in progress",
"cosi_connected": "Cosi connected",
"blocked": "Blocked",
"waiting_for_wakeup": "Waiting for wakeup",
"remote_node_deleted": "Remote node deleted",
"firmware_update_in_progress": "Firmware update in progress"
}
},
"operating_hours": {
"name": "Operating hours"
@ -136,5 +152,10 @@
}
}
}
},
"exceptions": {
"connection_closed": {
"message": "Could not connect to Homee while setting attribute"
}
}
}

View File

@ -78,6 +78,7 @@ from .const import (
CONF_VIDEO_CODEC,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
CONF_VIDEO_PROFILE_NAMES,
DEFAULT_AUDIO_CODEC,
DEFAULT_AUDIO_MAP,
DEFAULT_AUDIO_PACKET_SIZE,
@ -90,6 +91,7 @@ from .const import (
DEFAULT_VIDEO_CODEC,
DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE,
DEFAULT_VIDEO_PROFILE_NAMES,
DOMAIN,
FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE,
@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
VALID_VIDEO_CODECS
),
vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [
cv.string
],
vol.Optional(
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
): cv.positive_int,

View File

@ -12,6 +12,6 @@
"iot_class": "local_polling",
"loggers": ["homewizard_energy"],
"quality_scale": "platinum",
"requirements": ["python-homewizard-energy==v8.3.0"],
"requirements": ["python-homewizard-energy==v8.3.2"],
"zeroconf": ["_hwenergy._tcp.local.", "_homewizard._tcp.local."]
}

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/imap",
"iot_class": "cloud_push",
"loggers": ["aioimaplib"],
"requirements": ["aioimaplib==2.0.0"]
"requirements": ["aioimaplib==2.0.1"]
}

View File

@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -198,14 +198,13 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity):
# Handle ISY precision and rounding
value = convert_isy_value_to_hass(value, uom, self.target.prec)
if value is None:
return None
# Convert temperatures to Home Assistant's unit
if uom in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT):
value = self.hass.config.units.temperature(value, uom)
if value is None:
return None
assert isinstance(value, (int, float))
return value

View File

@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["jellyfin_apiclient_python"],
"requirements": ["jellyfin-apiclient-python==1.9.2"],
"requirements": ["jellyfin-apiclient-python==1.10.0"],
"single_config_entry": true
}

View File

@ -12,7 +12,7 @@
"requirements": [
"xknx==3.5.0",
"xknxproject==3.8.1",
"knx-frontend==2025.1.28.225404"
"knx-frontend==2025.1.30.194235"
],
"single_config_entry": true
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/lacrosse_view",
"iot_class": "cloud_polling",
"loggers": ["lacrosse_view"],
"requirements": ["lacrosse-view==1.0.3"]
"requirements": ["lacrosse-view==1.0.4"]
}

View File

@ -95,6 +95,9 @@
"drink_stats_flushing": {
"default": "mdi:chart-line"
},
"drink_stats_coffee_key": {
"default": "mdi:chart-scatter-plot"
},
"shot_timer": {
"default": "mdi:timer"
},

View File

@ -3,7 +3,7 @@
from collections.abc import Callable
from dataclasses import dataclass
from pylamarzocco.const import BoilerType, MachineModel
from pylamarzocco.const import KEYS_PER_MODEL, BoilerType, MachineModel, PhysicalKey
from pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.components.sensor import (
@ -21,7 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry
from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
# Coordinator is used to centralize the data updates
@ -37,6 +37,15 @@ class LaMarzoccoSensorEntityDescription(
value_fn: Callable[[LaMarzoccoMachine], float | int]
@dataclass(frozen=True, kw_only=True)
class LaMarzoccoKeySensorEntityDescription(
LaMarzoccoEntityDescription, SensorEntityDescription
):
"""Description of a keyed La Marzocco sensor."""
value_fn: Callable[[LaMarzoccoMachine, PhysicalKey], int | None]
ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="shot_timer",
@ -79,7 +88,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="drink_stats_coffee",
translation_key="drink_stats_coffee",
native_unit_of_measurement="drinks",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: device.statistics.total_coffee,
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
@ -88,7 +96,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="drink_stats_flushing",
translation_key="drink_stats_flushing",
native_unit_of_measurement="drinks",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: device.statistics.total_flushes,
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
@ -96,6 +103,18 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
),
)
KEY_STATISTIC_ENTITIES: tuple[LaMarzoccoKeySensorEntityDescription, ...] = (
LaMarzoccoKeySensorEntityDescription(
key="drink_stats_coffee_key",
translation_key="drink_stats_coffee_key",
state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device, key: device.statistics.drink_stats.get(key),
available_fn=lambda device: len(device.statistics.drink_stats) > 0,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
)
SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription(
key="scale_battery",
@ -120,6 +139,8 @@ async def async_setup_entry(
"""Set up sensor entities."""
config_coordinator = entry.runtime_data.config_coordinator
entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = []
entities = [
LaMarzoccoSensorEntity(config_coordinator, description)
for description in ENTITIES
@ -142,6 +163,14 @@ async def async_setup_entry(
if description.supported_fn(statistics_coordinator)
)
num_keys = KEYS_PER_MODEL[MachineModel(config_coordinator.device.model)]
if num_keys > 0:
entities.extend(
LaMarzoccoKeySensorEntity(statistics_coordinator, description, key)
for description in KEY_STATISTIC_ENTITIES
for key in range(1, num_keys + 1)
)
def _async_add_new_scale() -> None:
async_add_entities(
LaMarzoccoScaleSensorEntity(config_coordinator, description)
@ -159,11 +188,36 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
entity_description: LaMarzoccoSensorEntityDescription
@property
def native_value(self) -> int | float:
def native_value(self) -> int | float | None:
"""State of the sensor."""
return self.entity_description.value_fn(self.coordinator.device)
class LaMarzoccoKeySensorEntity(LaMarzoccoEntity, SensorEntity):
"""Sensor for a La Marzocco key."""
entity_description: LaMarzoccoKeySensorEntityDescription
def __init__(
self,
coordinator: LaMarzoccoUpdateCoordinator,
description: LaMarzoccoKeySensorEntityDescription,
key: int,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, description)
self.key = key
self._attr_translation_placeholders = {"key": str(key)}
self._attr_unique_id = f"{super()._attr_unique_id}_key{key}"
@property
def native_value(self) -> int | None:
"""State of the sensor."""
return self.entity_description.value_fn(
self.coordinator.device, PhysicalKey(self.key)
)
class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
"""Sensor for a La Marzocco scale."""

View File

@ -175,10 +175,16 @@
"name": "Current steam temperature"
},
"drink_stats_coffee": {
"name": "Total coffees made"
"name": "Total coffees made",
"unit_of_measurement": "coffees"
},
"drink_stats_coffee_key": {
"name": "Coffees made Key {key}",
"unit_of_measurement": "coffees"
},
"drink_stats_flushing": {
"name": "Total flushes made"
"name": "Total flushes made",
"unit_of_measurement": "flushes"
},
"shot_timer": {
"name": "Shot timer"

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push",
"loggers": ["pypck"],
"requirements": ["pypck==0.8.3", "lcn-frontend==0.2.3"]
"requirements": ["pypck==0.8.5", "lcn-frontend==0.2.3"]
}

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["bluetooth-data-tools==1.22.0", "ld2410-ble==0.1.1"]
"requirements": ["bluetooth-data-tools==1.23.3", "ld2410-ble==0.1.1"]
}

View File

@ -35,5 +35,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling",
"requirements": ["bluetooth-data-tools==1.22.0", "led-ble==1.1.4"]
"requirements": ["bluetooth-data-tools==1.23.3", "led-ble==1.1.4"]
}

View File

@ -23,7 +23,7 @@ from .const import (
)
from .coordinator import LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [Platform.TIME]
PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME]
type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]

View File

@ -1,5 +1,11 @@
"""Base class for LetPot entities."""
from collections.abc import Callable, Coroutine
from typing import Any, Concatenate
from letpot.exceptions import LetPotConnectionException, LetPotException
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -23,3 +29,27 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
model_id=coordinator.device_client.device_model_code,
serial_number=coordinator.device.serial_number,
)
def exception_handler[_EntityT: LetPotEntity, **_P](
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
"""Decorate the function to catch LetPot exceptions and raise them correctly."""
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
try:
await func(self, *args, **kwargs)
except LetPotConnectionException as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"exception": str(exception)},
) from exception
except LetPotException as exception:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"exception": str(exception)},
) from exception
return handler

View File

@ -0,0 +1,24 @@
{
"entity": {
"switch": {
"alarm_sound": {
"default": "mdi:bell-ring",
"state": {
"off": "mdi:bell-off"
}
},
"auto_mode": {
"default": "mdi:water-pump",
"state": {
"off": "mdi:water-pump-off"
}
},
"pump_cycling": {
"default": "mdi:pump",
"state": {
"off": "mdi:pump-off"
}
}
}
}
}

View File

@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"quality_scale": "bronze",
"requirements": ["letpot==0.3.0"]
"requirements": ["letpot==0.4.0"]
}

View File

@ -29,7 +29,7 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading:
status: done
comment: |
@ -63,8 +63,8 @@ rules:
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo

View File

@ -32,6 +32,20 @@
}
},
"entity": {
"switch": {
"alarm_sound": {
"name": "Alarm sound"
},
"auto_mode": {
"name": "Auto mode"
},
"power": {
"name": "Power"
},
"pump_cycling": {
"name": "Pump cycling"
}
},
"time": {
"light_schedule_end": {
"name": "Light off"
@ -40,5 +54,13 @@
"name": "Light on"
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the LetPot device: {exception}"
},
"unknown_error": {
"message": "An unknown error occurred while communicating with the LetPot device: {exception}"
}
}
}

View File

@ -0,0 +1,119 @@
"""Support for LetPot switch entities."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from letpot.deviceclient import LetPotDeviceClient
from letpot.models import DeviceFeature, LetPotDeviceStatus
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import LetPotConfigEntry
from .coordinator import LetPotDeviceCoordinator
from .entity import LetPotEntity, exception_handler
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class LetPotSwitchEntityDescription(SwitchEntityDescription):
"""Describes a LetPot switch entity."""
value_fn: Callable[[LetPotDeviceStatus], bool | None]
set_value_fn: Callable[[LetPotDeviceClient, bool], Coroutine[Any, Any, None]]
BASE_SWITCHES: tuple[LetPotSwitchEntityDescription, ...] = (
LetPotSwitchEntityDescription(
key="power",
translation_key="power",
value_fn=lambda status: status.system_on,
set_value_fn=lambda device_client, value: device_client.set_power(value),
entity_category=EntityCategory.CONFIG,
),
LetPotSwitchEntityDescription(
key="pump_cycling",
translation_key="pump_cycling",
value_fn=lambda status: status.pump_mode == 1,
set_value_fn=lambda device_client, value: device_client.set_pump_mode(value),
entity_category=EntityCategory.CONFIG,
),
)
ALARM_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
key="alarm_sound",
translation_key="alarm_sound",
value_fn=lambda status: status.system_sound,
set_value_fn=lambda device_client, value: device_client.set_sound(value),
entity_category=EntityCategory.CONFIG,
)
AUTO_MODE_SWITCH: LetPotSwitchEntityDescription = LetPotSwitchEntityDescription(
key="auto_mode",
translation_key="auto_mode",
value_fn=lambda status: status.water_mode == 1,
set_value_fn=lambda device_client, value: device_client.set_water_mode(value),
entity_category=EntityCategory.CONFIG,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LetPotConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up LetPot switch entities based on a config entry and device status/features."""
coordinators = entry.runtime_data
entities: list[SwitchEntity] = [
LetPotSwitchEntity(coordinator, description)
for description in BASE_SWITCHES
for coordinator in coordinators
]
entities.extend(
LetPotSwitchEntity(coordinator, ALARM_SWITCH)
for coordinator in coordinators
if coordinator.data.system_sound is not None
)
entities.extend(
LetPotSwitchEntity(coordinator, AUTO_MODE_SWITCH)
for coordinator in coordinators
if DeviceFeature.PUMP_AUTO in coordinator.device_client.device_features
)
async_add_entities(entities)
class LetPotSwitchEntity(LetPotEntity, SwitchEntity):
"""Defines a LetPot switch entity."""
entity_description: LetPotSwitchEntityDescription
def __init__(
self,
coordinator: LetPotDeviceCoordinator,
description: LetPotSwitchEntityDescription,
) -> None:
"""Initialize LetPot switch entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{coordinator.device.serial_number}_{description.key}"
@property
def is_on(self) -> bool | None:
"""Return if the entity is on."""
return self.entity_description.value_fn(self.coordinator.data)
@exception_handler
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
await self.entity_description.set_value_fn(self.coordinator.device_client, True)
@exception_handler
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
await self.entity_description.set_value_fn(
self.coordinator.device_client, False
)

View File

@ -15,7 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import LetPotConfigEntry
from .coordinator import LetPotDeviceCoordinator
from .entity import LetPotEntity
from .entity import LetPotEntity, exception_handler
# Each change pushes a 'full' device status with the change. The library will cache
# pending changes to avoid overwriting, but try to avoid a lot of parallelism.
@ -86,6 +86,7 @@ class LetPotTimeEntity(LetPotEntity, TimeEntity):
"""Return the time."""
return self.entity_description.value_fn(self.coordinator.data)
@exception_handler
async def async_set_value(self, value: time) -> None:
"""Set the time."""
await self.entity_description.set_value_fn(

View File

@ -113,7 +113,7 @@ def find_hsbk(hass: HomeAssistant, **kwargs: Any) -> list[float | int | None] |
saturation = int(saturation / 100 * 65535)
kelvin = 3500
if _ATTR_COLOR_TEMP in kwargs:
if ATTR_COLOR_TEMP_KELVIN not in kwargs and _ATTR_COLOR_TEMP in kwargs:
# added in 2025.1, can be removed in 2026.1
_LOGGER.warning(
"The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for"

View File

@ -0,0 +1 @@
"""Virtual integration: Linx."""

View File

@ -0,0 +1,6 @@
{
"domain": "linx",
"name": "Linx",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

Some files were not shown because too many files have changed in this diff Show More