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 fixes or closes issue: fixes #
- This PR is related to issue: - This PR is related to issue:
- Link to documentation pull request: - Link to documentation pull request:
- Link to developer documentation pull request:
- Link to frontend pull request:
## Checklist ## Checklist
<!-- <!--

4
CODEOWNERS generated
View File

@ -625,8 +625,8 @@ build.json @home-assistant/supervisor
/tests/components/hlk_sw16/ @jameshilliard /tests/components/hlk_sw16/ @jameshilliard
/homeassistant/components/holiday/ @jrieger @gjohansson-ST /homeassistant/components/holiday/ @jrieger @gjohansson-ST
/tests/components/holiday/ @jrieger @gjohansson-ST /tests/components/holiday/ @jrieger @gjohansson-ST
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 /homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
/tests/components/home_connect/ @DavidMStraub @Diegorro98 /tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
/homeassistant/components/homeassistant/ @home-assistant/core /homeassistant/components/homeassistant/ @home-assistant/core
/tests/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core
/homeassistant/components/homeassistant_alerts/ @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 # integrations can be removed and database migration status is
# visible in frontend # visible in frontend
"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 = { RECORDER_INTEGRATIONS = {
# Setup after frontend # Setup after frontend

View File

@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={}) 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): for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:

View File

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

View File

@ -272,6 +272,7 @@ class AnthropicConversationEntity(
continue continue
tool_input = llm.ToolInput( tool_input = llm.ToolInput(
id=tool_call.id,
tool_name=tool_call.name, tool_name=tool_call.name,
tool_args=cast(dict[str, Any], tool_call.input), 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={} 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): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: 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.components import stt
from homeassistant.core import Context, HomeAssistant from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import chat_session
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream(
Raises PipelineNotFound if no pipeline is found. Raises PipelineNotFound if no pipeline is found.
""" """
pipeline_input = PipelineInput( with chat_session.async_get_chat_session(hass, conversation_id) as session:
conversation_id=conversation_id, pipeline_input = PipelineInput(
device_id=device_id, conversation_id=session.conversation_id,
stt_metadata=stt_metadata, device_id=device_id,
stt_stream=stt_stream, stt_metadata=stt_metadata,
wake_word_phrase=wake_word_phrase, stt_stream=stt_stream,
conversation_extra_system_prompt=conversation_extra_system_prompt, wake_word_phrase=wake_word_phrase,
run=PipelineRun( conversation_extra_system_prompt=conversation_extra_system_prompt,
hass, run=PipelineRun(
context=context, hass,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), context=context,
start_stage=start_stage, pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
end_stage=end_stage, start_stage=start_stage,
event_callback=event_callback, end_stage=end_stage,
tts_audio_output=tts_audio_output, event_callback=event_callback,
wake_word_settings=wake_word_settings, tts_audio_output=tts_audio_output,
audio_settings=audio_settings or AudioSettings(), wake_word_settings=wake_word_settings,
), audio_settings=audio_settings or AudioSettings(),
) ),
await pipeline_input.validate() )
await pipeline_input.execute() 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.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent from homeassistant.helpers import chat_session, intent
from homeassistant.helpers.collection import ( from homeassistant.helpers.collection import (
CHANGE_UPDATED, CHANGE_UPDATED,
CollectionError, CollectionError,
@ -624,7 +624,7 @@ class PipelineRun:
return return
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event) 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.""" """Emit run start event."""
self._device_id = device_id self._device_id = device_id
self._start_debug_recording_thread() self._start_debug_recording_thread()
@ -632,6 +632,7 @@ class PipelineRun:
data = { data = {
"pipeline": self.pipeline.id, "pipeline": self.pipeline.id,
"language": self.language, "language": self.language,
"conversation_id": conversation_id,
} }
if self.runner_data is not None: if self.runner_data is not None:
data["runner_data"] = self.runner_data data["runner_data"] = self.runner_data
@ -1015,7 +1016,7 @@ class PipelineRun:
async def recognize_intent( async def recognize_intent(
self, self,
intent_input: str, intent_input: str,
conversation_id: str | None, conversation_id: str,
device_id: str | None, device_id: str | None,
conversation_extra_system_prompt: str | None, conversation_extra_system_prompt: str | None,
) -> str: ) -> str:
@ -1063,11 +1064,11 @@ class PipelineRun:
agent_id=self.intent_agent, agent_id=self.intent_agent,
extra_system_prompt=conversation_extra_system_prompt, 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 intent_response: intent.IntentResponse | None = None
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT: if not processed_locally:
# Sentence triggers override conversation agent # Sentence triggers override conversation agent
if ( if (
trigger_response_text trigger_response_text
@ -1094,22 +1095,27 @@ class PipelineRun:
# It was already handled, create response and add to chat history # It was already handled, create response and add to chat history
if intent_response is not None: if intent_response is not None:
async with conversation.async_get_chat_session( with (
self.hass, user_input chat_session.async_get_chat_session(
) as 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: str = intent_response.speech.get("plain", {}).get(
"speech", "" "speech", ""
) )
chat_session.async_add_message( async for _ in chat_log.async_add_assistant_content(
conversation.Content( conversation.AssistantContent(
role="assistant",
agent_id=agent_id, agent_id=agent_id,
content=speech, content=speech,
) )
) ):
pass
conversation_result = conversation.ConversationResult( conversation_result = conversation.ConversationResult(
response=intent_response, response=intent_response,
conversation_id=chat_session.conversation_id, conversation_id=session.conversation_id,
) )
else: else:
@ -1404,12 +1410,15 @@ def _pipeline_debug_recording_thread_proc(
wav_writer.close() wav_writer.close()
@dataclass @dataclass(kw_only=True)
class PipelineInput: class PipelineInput:
"""Input to a pipeline run.""" """Input to a pipeline run."""
run: PipelineRun run: PipelineRun
conversation_id: str
"""Identifier for the conversation."""
stt_metadata: stt.SpeechMetadata | None = None stt_metadata: stt.SpeechMetadata | None = None
"""Metadata of stt input audio. Required when start_stage = stt.""" """Metadata of stt input audio. Required when start_stage = stt."""
@ -1425,9 +1434,6 @@ class PipelineInput:
tts_input: str | None = None tts_input: str | None = None
"""Input for text-to-speech. Required when start_stage = tts.""" """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 conversation_extra_system_prompt: str | None = None
"""Extra prompt information for the conversation agent.""" """Extra prompt information for the conversation agent."""
@ -1436,7 +1442,7 @@ class PipelineInput:
async def execute(self) -> None: async def execute(self) -> None:
"""Run pipeline.""" """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 current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = [] stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None 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.components import conversation, stt, tts, websocket_api
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
from homeassistant.core import HomeAssistant, callback 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 homeassistant.util import language as language_util
from .const import ( from .const import (
@ -145,7 +149,6 @@ async def websocket_run(
# Arguments to PipelineInput # Arguments to PipelineInput
input_args: dict[str, Any] = { input_args: dict[str, Any] = {
"conversation_id": msg.get("conversation_id"),
"device_id": msg.get("device_id"), "device_id": msg.get("device_id"),
} }
@ -233,38 +236,42 @@ async def websocket_run(
audio_settings=audio_settings or AudioSettings(), 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: try:
await pipeline_input.validate() await pipeline_input.validate()
except PipelineError as error: except PipelineError as error:
# Report more specific error when possible # Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message) connection.send_error(msg["id"], error.code, error.message)
return return
# Confirm subscription # Confirm subscription
connection.send_result(msg["id"]) 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 # Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel connection.subscriptions[msg["id"]] = run_task.cancel
try: try:
# Task contains a timeout # Task contains a timeout
async with asyncio.timeout(timeout): async with asyncio.timeout(timeout):
await run_task await run_task
except TimeoutError: except TimeoutError:
pipeline_input.run.process_event( pipeline_input.run.process_event(
PipelineEvent( PipelineEvent(
PipelineEventType.ERROR, PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"}, {"code": "timeout", "message": "Timeout running pipeline"},
)
) )
) finally:
finally: if unregister_handler is not None:
if unregister_handler is not None: # Unregister binary handler
# Unregister binary handler unregister_handler()
unregister_handler()
@callback @callback

View File

@ -8,7 +8,7 @@ from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
import logging import logging
import time 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 import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import ( from homeassistant.components.assist_pipeline import (
@ -28,14 +28,12 @@ from homeassistant.components.tts import (
) )
from homeassistant.core import Context, callback from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from .const import AssistSatelliteEntityFeature from .const import AssistSatelliteEntityFeature
from .errors import AssistSatelliteError, SatelliteBusyError from .errors import AssistSatelliteError, SatelliteBusyError
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -114,7 +112,6 @@ class AssistSatelliteEntity(entity.Entity):
_attr_vad_sensitivity_entity_id: str | None = None _attr_vad_sensitivity_entity_id: str | None = None
_conversation_id: str | None = None _conversation_id: str | None = None
_conversation_id_time: float | None = None
_run_has_tts: bool = False _run_has_tts: bool = False
_is_announcing = False _is_announcing = False
@ -260,11 +257,25 @@ class AssistSatelliteEntity(entity.Entity):
else: else:
self._extra_system_prompt = start_message or None 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: try:
await self.async_start_conversation(announcement) await self.async_start_conversation(announcement)
finally: finally:
self._is_announcing = False self._is_announcing = False
self._extra_system_prompt = None
async def async_start_conversation( async def async_start_conversation(
self, start_announcement: AssistSatelliteAnnouncement self, start_announcement: AssistSatelliteAnnouncement
@ -282,6 +293,10 @@ class AssistSatelliteEntity(entity.Entity):
"""Triggers an Assist pipeline in Home Assistant from a satellite.""" """Triggers an Assist pipeline in Home Assistant from a satellite."""
await self._cancel_running_pipeline() 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 ( if self._wake_word_intercept_future and start_stage in (
PipelineStage.WAKE_WORD, PipelineStage.WAKE_WORD,
PipelineStage.STT, PipelineStage.STT,
@ -322,51 +337,52 @@ class AssistSatelliteEntity(entity.Entity):
assert self._context is not None 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 # Set entity state based on pipeline events
self._run_has_tts = False self._run_has_tts = False
assert self.platform.config_entry is not None 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: with chat_session.async_get_chat_session(
await self._pipeline_task self.hass, self._conversation_id
finally: ) as session:
self._pipeline_task = None # 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: async def _cancel_running_pipeline(self) -> None:
"""Cancel the current pipeline if it's running.""" """Cancel the current pipeline if it's running."""
@ -390,11 +406,6 @@ class AssistSatelliteEntity(entity.Entity):
self._set_state(AssistSatelliteState.LISTENING) self._set_state(AssistSatelliteState.LISTENING)
elif event.type is PipelineEventType.INTENT_START: elif event.type is PipelineEventType.INTENT_START:
self._set_state(AssistSatelliteState.PROCESSING) 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: elif event.type is PipelineEventType.TTS_START:
# Wait until tts_response_finished is called to return to waiting state # Wait until tts_response_finished is called to return to waiting state
self._run_has_tts = True self._run_has_tts = True

View File

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

View File

@ -35,6 +35,7 @@ from .manager import (
WrittenBackup, WrittenBackup,
) )
from .models import AddonInfo, AgentBackup, Folder from .models import AddonInfo, AgentBackup, Folder
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers from .websocket import async_register_websocket_handlers
__all__ = [ __all__ = [
@ -58,6 +59,8 @@ __all__ = [
"RestoreBackupState", "RestoreBackupState",
"WrittenBackup", "WrittenBackup",
"async_get_manager", "async_get_manager",
"suggested_filename",
"suggested_filename_from_name_date",
] ]
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) 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 .agent import BackupAgent, BackupNotFound, LocalBackupAgent
from .const import DOMAIN, LOGGER from .const import DOMAIN, LOGGER
from .models import AgentBackup from .models import AgentBackup
from .util import read_backup from .util import read_backup, suggested_filename
async def async_get_backup_agents( async def async_get_backup_agents(
@ -123,7 +123,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
def get_new_backup_path(self, backup: AgentBackup) -> Path: def get_new_backup_path(self, backup: AgentBackup) -> Path:
"""Return the local path to a new backup.""" """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: async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
"""Delete a backup file.""" """Delete a backup file."""

View File

@ -2,8 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
import datetime as dt import datetime as dt
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -252,7 +250,7 @@ class RetentionConfig:
"""Delete backups older than days.""" """Delete backups older than days."""
self._schedule_next(manager) self._schedule_next(manager)
def _backups_filter( def _delete_filter(
backups: dict[str, ManagerBackup], backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]: ) -> dict[str, ManagerBackup]:
"""Return backups older than days to delete.""" """Return backups older than days to delete."""
@ -269,7 +267,9 @@ class RetentionConfig:
< now < 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.remove_next_delete_event = async_call_later(
manager.hass, timedelta(days=1), _delete_backups manager.hass, timedelta(days=1), _delete_backups
@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False):
password: str | None password: str | None
async def _delete_filtered_backups( def _automatic_backups_filter(
manager: BackupManager, backups: dict[str, ManagerBackup],
backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]], ) -> dict[str, ManagerBackup]:
) -> None: """Return automatic backups."""
"""Delete backups parsed with a filter. return {
: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 = {
backup_id: backup backup_id: backup
for backup_id, backup in backups.items() for backup_id, backup in backups.items()
if backup.with_automatic_settings 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: async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
"""Delete backups exceeding the configured retention count.""" """Delete backups exceeding the configured retention count."""
def _backups_filter( def _delete_filter(
backups: dict[str, ManagerBackup], backups: dict[str, ManagerBackup],
) -> dict[str, ManagerBackup]: ) -> dict[str, ManagerBackup]:
"""Return oldest backups more numerous than copies to delete.""" """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)] )[: 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 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( async def async_receive_backup(
self, self,
*, *,
@ -898,7 +962,7 @@ class BackupManager:
) )
backup_name = ( backup_name = (
name (name if name is None else name.strip())
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}" or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
) )
extra_metadata = extra_metadata or {} 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.backup_restore import password_to_key
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError 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.json import JsonObjectType, json_loads_object
from homeassistant.util.thread import ThreadWithException 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: def validate_password(path: Path, password: str | None) -> bool:
"""Validate the password.""" """Validate the password."""
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file: 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_database", default=True): bool,
vol.Optional("include_folders"): [vol.Coerce(Folder)], vol.Optional("include_folders"): [vol.Coerce(Folder)],
vol.Optional("include_homeassistant", default=True): bool, 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), vol.Optional("password"): vol.Any(str, None),
} }
) )

View File

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

View File

@ -80,6 +80,7 @@ from .const import (
CONF_DETAILS, CONF_DETAILS,
CONF_PASSIVE, CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
@ -297,7 +298,12 @@ async def async_discover_adapters(
async def async_update_device( 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: ) -> None:
"""Update device registry entry. """Update device registry entry.
@ -306,7 +312,8 @@ async def async_update_device(
update the device with the new location so they can update the device with the new location so they can
figure out where the adapter is. 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, config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]), name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, 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), sw_version=details.get(ADAPTER_SW_VERSION),
hw_version=details.get(ADAPTER_HW_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: 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, entry,
source_entry.title, source_entry.title,
details, details,
source_domain,
entry.data.get(CONF_SOURCE_DEVICE_ID),
) )
return True return True
manager = _get_manager(hass) manager = _get_manager(hass)

View File

@ -181,10 +181,16 @@ def async_register_scanner(
source_domain: str | None = None, source_domain: str | None = None,
source_model: str | None = None, source_model: str | None = None,
source_config_entry_id: str | None = None, source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a BleakScanner.""" """Register a BleakScanner."""
return _get_manager(hass).async_register_hass_scanner( 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_PASSIVE,
CONF_SOURCE, CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL], CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN], CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID], 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) self._abort_if_unique_id_configured(updates=data)
manager = get_manager() manager = get_manager()

View File

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

View File

@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import ( from .const import (
CONF_SOURCE, CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID, CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN, CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL, CONF_SOURCE_MODEL,
DOMAIN, DOMAIN,
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
source_domain: str | None = None, source_domain: str | None = None,
source_model: str | None = None, source_model: str | None = None,
source_config_entry_id: str | None = None, source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Register a scanner.""" """Register a scanner."""
cancel = self.async_register_scanner(scanner, connection_slots) cancel = self.async_register_scanner(scanner, connection_slots)
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
isinstance(scanner, BaseHaRemoteScanner) isinstance(scanner, BaseHaRemoteScanner)
and source_domain and source_domain
and source_config_entry_id 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.async_create_task(
self.hass.config_entries.flow.async_init( self.hass.config_entries.flow.async_init(
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
CONF_SOURCE_DOMAIN: source_domain, CONF_SOURCE_DOMAIN: source_domain,
CONF_SOURCE_MODEL: source_model, CONF_SOURCE_MODEL: source_model,
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id, 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", "bleak-retry-connector==3.8.0",
"bluetooth-adapters==0.21.1", "bluetooth-adapters==0.21.1",
"bluetooth-auto-recovery==1.4.2", "bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.22.0", "bluetooth-data-tools==1.23.3",
"dbus-fast==2.30.2", "dbus-fast==2.32.0",
"habluetooth==3.14.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.const import CONF_EMAIL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
config_entry: ConfigEntry config_entry: ConfigEntry
user_settings: BringUserSettingsResponse user_settings: BringUserSettingsResponse
lists: list[BringList]
def __init__(self, hass: HomeAssistant, bring: Bring) -> None: def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
"""Initialize the Bring data coordinator.""" """Initialize the Bring data coordinator."""
@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
update_interval=timedelta(seconds=90), update_interval=timedelta(seconds=90),
) )
self.bring = bring self.bring = bring
self.previous_lists: set[str] = set()
async def _async_update_data(self) -> dict[str, BringData]: async def _async_update_data(self) -> dict[str, BringData]:
"""Fetch the latest data from bring."""
try: try:
lists_response = await self.bring.load_lists() self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e: except BringRequestException as e:
raise UpdateFailed("Unable to connect and retrieve data from bring") from e raise UpdateFailed("Unable to connect and retrieve data from bring") from e
except BringParseException as e: except BringParseException as e:
@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
) from exc ) from exc
return self.data 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] = {} 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: if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
continue continue
try: try:
@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
try: try:
await self.bring.login() await self.bring.login()
self.user_settings = await self.bring.get_all_user_settings() self.user_settings = await self.bring.get_all_user_settings()
self.lists = (await self.bring.load_lists()).lists
except BringRequestException as e: except BringRequestException as e:
raise ConfigEntryNotReady( raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
translation_key="setup_authentication_exception", translation_key="setup_authentication_exception",
translation_placeholders={CONF_EMAIL: self.bring.mail}, translation_placeholders={CONF_EMAIL: self.bring.mail},
) from e ) 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 __future__ import annotations
from bring_api.types import BringList
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import BringData, BringDataUpdateCoordinator from .coordinator import BringDataUpdateCoordinator
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]): class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
def __init__( def __init__(
self, self,
coordinator: BringDataUpdateCoordinator, coordinator: BringDataUpdateCoordinator,
bring_list: BringData, bring_list: BringList,
) -> None: ) -> None:
"""Initialize the entity.""" """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( self.device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
name=bring_list.lst.name, name=bring_list.name,
identifiers={ identifiers={
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}") (DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
}, },
manufacturer="Bring! Labs AG", manufacturer="Bring! Labs AG",
model="Bring! Grocery Shopping List", 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-supported-functions: todo
docs-troubleshooting: todo docs-troubleshooting: todo
docs-use-cases: todo docs-use-cases: todo
dynamic-devices: todo dynamic-devices: done
entity-category: done entity-category: done
entity-device-class: done entity-device-class: done
entity-disabled-by-default: done entity-disabled-by-default: done
@ -65,7 +65,7 @@ rules:
status: exempt status: exempt
comment: | comment: |
no repairs no repairs
stale-devices: todo stale-devices: done
# Platinum # Platinum
async-dependency: done async-dependency: done
inject-websession: done inject-websession: done

View File

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

View File

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

View File

@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry() 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): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: if address in current_addresses or address in self._discovered_devices:

View File

@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome", "documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push", "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, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, 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 (-) # Count (-)
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription( (BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
key=str(BTHomeSensorDeviceClass.COUNT), key=str(BTHomeSensorDeviceClass.COUNT),
@ -99,6 +109,12 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfTemperature.CELSIUS, native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT, 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) # Distance (mm)
( (
BTHomeSensorDeviceClass.DISTANCE, BTHomeSensorDeviceClass.DISTANCE,
@ -221,6 +237,16 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfPower.WATT, native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT, 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) # Pressure (mbar)
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription( (BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}", key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
@ -357,16 +383,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfVolume.LITERS, native_unit_of_measurement=UnitOfVolume.LITERS,
state_class=SensorStateClass.TOTAL, 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`" 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): try:
image = ( async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
await _async_get_stream_image(camera, wait_for_next_keyframe=True) image = (
if camera.use_stream_for_stills await _async_get_stream_image(camera, wait_for_next_keyframe=True)
else await camera.async_camera_image() 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: if image is None:
return return
@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service(
try: try:
await hass.async_add_executor_job(_write_image, snapshot_file, image) await hass.async_add_executor_job(_write_image, snapshot_file, image)
except OSError as err: 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( async def async_handle_play_stream_service(

View File

@ -30,6 +30,16 @@ from .agent_manager import (
async_get_agent, async_get_agent,
get_agent_manager, get_agent_manager,
) )
from .chat_log import (
AssistantContent,
ChatLog,
Content,
ConverseError,
SystemContent,
ToolResultContent,
UserContent,
async_get_chat_log,
)
from .const import ( from .const import (
ATTR_AGENT_ID, ATTR_AGENT_ID,
ATTR_CONVERSATION_ID, ATTR_CONVERSATION_ID,
@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult 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 from .trace import ConversationTraceEventType, async_conversation_trace_append
__all__ = [ __all__ = [
"DOMAIN", "DOMAIN",
"HOME_ASSISTANT_AGENT", "HOME_ASSISTANT_AGENT",
"OLD_HOME_ASSISTANT_AGENT", "OLD_HOME_ASSISTANT_AGENT",
"ChatSession", "AssistantContent",
"ChatLog",
"Content", "Content",
"ConversationEntity", "ConversationEntity",
"ConversationEntityFeature", "ConversationEntityFeature",
@ -69,11 +73,13 @@ __all__ = [
"ConversationResult", "ConversationResult",
"ConversationTraceEventType", "ConversationTraceEventType",
"ConverseError", "ConverseError",
"NativeContent", "SystemContent",
"ToolResultContent",
"UserContent",
"async_conversation_trace_append", "async_conversation_trace_append",
"async_converse", "async_converse",
"async_get_agent_info", "async_get_agent_info",
"async_get_chat_session", "async_get_chat_log",
"async_set_agent", "async_set_agent",
"async_setup", "async_setup",
"async_unset_agent", "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.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.helpers import ( from homeassistant.helpers import (
area_registry as ar, area_registry as ar,
chat_session,
device_registry as dr, device_registry as dr,
entity_registry as er, entity_registry as er,
floor_registry as fr, 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.helpers.event import async_track_state_added_domain
from homeassistant.util.json import JsonObjectType, json_loads_object from homeassistant.util.json import JsonObjectType, json_loads_object
from .chat_log import AssistantContent, async_get_chat_log
from .const import ( from .const import (
DATA_DEFAULT_ENTITY, DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES, DEFAULT_EXPOSED_ATTRIBUTES,
@ -62,7 +64,6 @@ from .const import (
) )
from .entity import ConversationEntity from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult from .models import ConversationInput, ConversationResult
from .session import Content, async_get_chat_session
from .trace import ConversationTraceEventType, async_conversation_trace_append from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult: async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence.""" """Process a sentence."""
response: intent.IntentResponse | None = None 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 # Check if a trigger matched
if trigger_result := await self.async_recognize_sentence_trigger( if trigger_result := await self.async_recognize_sentence_trigger(
user_input user_input
@ -373,16 +379,16 @@ class DefaultAgent(ConversationEntity):
) )
speech: str = response.speech.get("plain", {}).get("speech", "") speech: str = response.speech.get("plain", {}).get("speech", "")
chat_session.async_add_message( async for _tool_result in chat_log.async_add_assistant_content(
Content( AssistantContent(
role="assistant", agent_id=user_input.agent_id, # type: ignore[arg-type]
agent_id=user_input.agent_id,
content=speech, content=speech,
) )
) ):
pass
return ConversationResult( return ConversationResult(
response=response, conversation_id=chat_session.conversation_id response=response, conversation_id=session.conversation_id
) )
async def _async_process_intent_result( 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", "quality_scale": "internal",
"requirements": [ "requirements": [
"aiodhcpwatcher==1.0.2", "aiodhcpwatcher==1.0.3",
"aiodiscover==2.1.0", "aiodiscover==2.1.0",
"cached-ipaddress==0.8.0" "cached-ipaddress==0.8.0"
] ]

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "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 typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.heater import EheimDigitalHeater from eheimdigital.heater import EheimDigitalHeater
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit 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.""" """Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None: def async_setup_device_entities(
"""Set up the light entities for a device.""" device_address: str | dict[str, EheimDigitalDevice],
device = coordinator.hub.devices[device_address] ) -> 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(entities)
async_add_entities([EheimDigitalHeaterClimate(coordinator, device)])
coordinator.add_platform_callback(async_setup_device_entities) coordinator.add_platform_callback(async_setup_device_entities)
for device_address in entry.runtime_data.hub.devices: async_setup_device_entities(coordinator.hub.devices)
await async_setup_device_entities(device_address)
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity): class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
@ -69,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
_attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_preset_mode = PRESET_NONE _attr_preset_mode = PRESET_NONE
_attr_translation_key = "heater" _attr_translation_key = "heater"
_attr_name = None
def __init__( def __init__(
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater

View File

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

View File

@ -3,6 +3,7 @@
from typing import Any from typing import Any
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.types import EheimDigitalClientError, LightMode from eheimdigital.types import EheimDigitalClientError, LightMode
from homeassistant.components.light import ( 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.""" """Set up the callbacks for the coordinator so lights can be added as devices are found."""
coordinator = entry.runtime_data coordinator = entry.runtime_data
async def async_setup_device_entities(device_address: str) -> None: def async_setup_device_entities(
"""Set up the light entities for a device.""" device_address: str | dict[str, EheimDigitalDevice],
device = coordinator.hub.devices[device_address] ) -> None:
"""Set up the light entities for one or multiple devices."""
entities: list[EheimDigitalClassicLEDControlLight] = [] 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) async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities) coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
for device_address in entry.runtime_data.hub.devices:
await async_setup_device_entities(device_address)
class EheimDigitalClassicLEDControlLight( class EheimDigitalClassicLEDControlLight(

View File

@ -3,7 +3,7 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Searching for Energenie-Power-Sockets Devices.", "title": "Searching for Energenie Power Sockets devices",
"description": "Choose a discovered device.", "description": "Choose a discovered device.",
"data": { "data": {
"device": "[%key:common::config_flow::data::device%]" "device": "[%key:common::config_flow::data::device%]"
@ -13,7 +13,7 @@
"abort": { "abort": {
"usb_error": "Couldn't access USB devices!", "usb_error": "Couldn't access USB devices!",
"no_device": "Unable to discover any (new) supported device.", "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%]" "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
} }
}, },

View File

@ -22,5 +22,5 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "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, entry_data: RuntimeEntryData,
cli: APIClient, cli: APIClient,
device_info: DeviceInfo, device_info: DeviceInfo,
device_id: str,
) -> CALLBACK_TYPE: ) -> CALLBACK_TYPE:
"""Connect scanner.""" """Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available) client_data = connect_scanner(cli, device_info, entry_data.available)
@ -45,6 +46,7 @@ def async_connect_scanner(
source_domain=DOMAIN, source_domain=DOMAIN,
source_model=device_info.model, source_model=device_info.model,
source_config_entry_id=entry_data.entry_id, source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
), ),
scanner.async_setup(), scanner.async_setup(),
], ],

View File

@ -425,7 +425,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version): if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add( 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: else:
bluetooth.async_remove_scanner(hass, device_info.mac_address) bluetooth.async_remove_scanner(hass, device_info.mac_address)
@ -571,7 +573,9 @@ def _async_setup_device_registry(
configuration_url = None configuration_url = None
if device_info.webserver_port > 0: 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 ( elif (
(dashboard := async_get_dashboard(hass)) (dashboard := async_get_dashboard(hass))
and dashboard.data and dashboard.data

View File

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

View File

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

View File

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

View File

@ -28,14 +28,14 @@
"user": { "user": {
"description": "Enter the settings to connect to the camera.", "description": "Enter the settings to connect to the camera.",
"data": { "data": {
"still_image_url": "Still Image URL (e.g. http://...)", "still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream Source URL (e.g. rtsp://...)", "stream_source": "Stream source URL (e.g. rtsp://...)",
"rtsp_transport": "RTSP transport protocol", "rtsp_transport": "RTSP transport protocol",
"authentication": "Authentication", "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%]", "password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"framerate": "Frame Rate (Hz)", "framerate": "Frame rate (Hz)",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" "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 aiohttp.client_exceptions import ClientError, ClientResponseError
from google_drive_api.api import AbstractAuth, GoogleDriveApi 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.config_entries import ConfigEntryState
from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.exceptions import ( from homeassistant.exceptions import (
@ -132,7 +132,7 @@ class DriveClient:
"""Upload a backup.""" """Upload a backup."""
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists() folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
backup_metadata = { backup_metadata = {
"name": f"{backup.name} {backup.date}.tar", "name": suggested_filename(backup),
"description": json.dumps(backup.as_dict()), "description": json.dumps(backup.as_dict()),
"parents": [folder_id], "parents": [folder_id],
"properties": { "properties": {

View File

@ -2,6 +2,7 @@
from homeassistant.components.application_credentials import AuthorizationServer from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: 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", "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/", "more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials", "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": { "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 import codecs
from collections.abc import Callable from collections.abc import Callable
from typing import Any, Literal from typing import Any, Literal, cast
from google.api_core.exceptions import GoogleAPIError from google.api_core.exceptions import GoogleAPIError
import google.generativeai as genai 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.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, intent, llm from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import ( from .const import (
@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any:
return value return value
def _chat_message_convert( def _create_google_tool_response_content(
message: conversation.Content | conversation.NativeContent[genai_types.ContentDict], content: list[conversation.ToolResultContent],
) -> genai_types.ContentDict: ) -> protos.Content:
"""Convert any native chat message for this agent to the native format.""" """Create a Google tool response content."""
if message.role == "native": return protos.Content(
return message.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( class GoogleGenerativeAIConversationEntity(
@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity(
self, user_input: conversation.ConversationInput self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult: ) -> conversation.ConversationResult:
"""Process a sentence.""" """Process a sentence."""
async with conversation.async_get_chat_session( with (
self.hass, user_input chat_session.async_get_chat_session(
) as session: self.hass, user_input.conversation_id
return await self._async_handle_message(user_input, session) ) 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( async def _async_handle_message(
self, self,
user_input: conversation.ConversationInput, user_input: conversation.ConversationInput,
session: conversation.ChatSession[genai_types.ContentDict], chat_log: conversation.ChatLog,
) -> conversation.ConversationResult: ) -> conversation.ConversationResult:
"""Call the API.""" """Call the API."""
@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity(
options = self.entry.options options = self.entry.options
try: try:
await session.async_update_llm_data( await chat_log.async_update_llm_data(
DOMAIN, DOMAIN,
user_input, user_input,
options.get(CONF_LLM_HASS_API), options.get(CONF_LLM_HASS_API),
@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity(
return err.as_conversation_result() return err.as_conversation_result()
tools: list[dict[str, Any]] | None = None tools: list[dict[str, Any]] | None = None
if session.llm_api: if chat_log.llm_api:
tools = [ tools = [
_format_tool(tool, session.llm_api.custom_serializer) _format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in session.llm_api.tools for tool in chat_log.llm_api.tools
] ]
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) 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 "gemini-1.0" not in model_name and "gemini-pro" not in model_name
) )
prompt, *messages = [ prompt = chat_log.content[0].content # type: ignore[union-attr]
_chat_message_convert(message) for message in session.async_get_messages() 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 = genai.GenerativeModel(
model_name=model_name, model_name=model_name,
generation_config={ generation_config={
@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity(
), ),
}, },
tools=tools or None, 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: if not supports_system_instruction:
messages = [ messages = [
{"role": "user", "parts": prompt["parts"]}, {"role": "user", "parts": prompt},
{"role": "model", "parts": "Ok"}, {"role": "model", "parts": "Ok"},
*messages, *messages,
] ]
@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity(
content = " ".join( content = " ".join(
[part.text.strip() for part in chat_response.parts if part.text] [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 = [ tool_calls = []
part.function_call for part in chat_response.parts if part.function_call for part in chat_response.parts:
] if not part.function_call:
continue
if not function_calls or not session.llm_api: tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
break
tool_responses = []
for function_call in function_calls:
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
tool_name = tool_call["name"] tool_name = tool_call["name"]
tool_args = _escape_decode(tool_call["args"]) tool_args = _escape_decode(tool_call["args"])
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args) tool_calls.append(
function_response = await session.async_call_tool(tool_input) llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
tool_responses.append( )
protos.Part(
function_response=protos.FunctionResponse( chat_request = _create_google_tool_response_content(
name=tool_name, response=function_response [
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 = intent.IntentResponse(language=user_input.language)
response.async_set_speech( response.async_set_speech(
" ".join([part.text.strip() for part in chat_response.parts if part.text]) " ".join([part.text.strip() for part in chat_response.parts if part.text])
) )
return conversation.ConversationResult( return conversation.ConversationResult(
response=response, conversation_id=session.conversation_id response=response, conversation_id=chat_log.conversation_id
) )
async def _async_entry_update_listener( 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} 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): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: 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", "documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["habiticalib"], "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 from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
import logging import logging
import os import os
from pathlib import Path from pathlib import Path, PurePath
from typing import Any, cast from typing import Any, cast
from uuid import UUID from uuid import UUID
@ -33,16 +33,20 @@ from homeassistant.components.backup import (
Folder, Folder,
IdleEvent, IdleEvent,
IncorrectPasswordError, IncorrectPasswordError,
ManagerBackup,
NewBackup, NewBackup,
RestoreBackupEvent, RestoreBackupEvent,
RestoreBackupState, RestoreBackupState,
WrittenBackup, WrittenBackup,
async_get_manager as async_get_backup_manager, 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.const import __version__ as HAVERSION
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client from .handler import get_supervisor_client
@ -51,6 +55,8 @@ LOCATION_CLOUD_BACKUP = ".cloud_backup"
LOCATION_LOCAL = ".local" LOCATION_LOCAL = ".local"
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount") MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID" 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__) _LOGGER = logging.getLogger(__name__)
@ -113,12 +119,15 @@ def _backup_details_to_agent_backup(
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version) AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
for addon in details.addons for addon in details.addons
] ]
extra_metadata = details.extra or {}
location = location or LOCATION_LOCAL location = location or LOCATION_LOCAL
return AgentBackup( return AgentBackup(
addons=addons, addons=addons,
backup_id=details.slug, backup_id=details.slug,
database_included=database_included, database_included=database_included,
date=details.date.isoformat(), date=extra_metadata.get(
"supervisor.backup_request_date", details.date.isoformat()
),
extra_metadata=details.extra or {}, extra_metadata=details.extra or {},
folders=[Folder(folder) for folder in details.folders], folders=[Folder(folder) for folder in details.folders],
homeassistant_included=homeassistant_included, homeassistant_included=homeassistant_included,
@ -174,7 +183,8 @@ class SupervisorBackupAgent(BackupAgent):
return return
stream = await open_stream() stream = await open_stream()
upload_options = supervisor_backups.UploadBackupOptions( upload_options = supervisor_backups.UploadBackupOptions(
location={self.location} location={self.location},
filename=PurePath(suggested_backup_filename(backup)),
) )
await self._client.backups.upload_backup( await self._client.backups.upload_backup(
stream, stream,
@ -301,6 +311,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
locations = [] locations = []
locations = locations or [LOCATION_CLOUD_BACKUP] 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: try:
backup = await self._client.backups.partial_backup( backup = await self._client.backups.partial_backup(
supervisor_backups.PartialBackupOptions( supervisor_backups.PartialBackupOptions(
@ -314,6 +327,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
homeassistant_exclude_database=not include_database, homeassistant_exclude_database=not include_database,
background=True, background=True,
extra=extra_metadata, extra=extra_metadata,
filename=PurePath(filename),
) )
) )
except SupervisorError as err: except SupervisorError as err:
@ -503,17 +517,22 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
raise HomeAssistantError(message) from err raise HomeAssistantError(message) from err
restore_complete = asyncio.Event() restore_complete = asyncio.Event()
restore_errors: list[dict[str, str]] = []
@callback @callback
def on_job_progress(data: Mapping[str, Any]) -> None: def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup restore progress.""" """Handle backup restore progress."""
if data.get("done") is True: if data.get("done") is True:
restore_complete.set() restore_complete.set()
restore_errors.extend(data.get("errors", []))
unsub = self._async_listen_job_events(job.job_id, on_job_progress) unsub = self._async_listen_job_events(job.job_id, on_job_progress)
try: try:
await self._get_job_state(job.job_id, on_job_progress) await self._get_job_state(job.job_id, on_job_progress)
await restore_complete.wait() 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: finally:
unsub() unsub()
@ -540,11 +559,23 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
) )
return return
on_progress( restore_errors = data.get("errors", [])
RestoreBackupEvent( if restore_errors:
reason="", stage=None, state=RestoreBackupState.COMPLETED _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()) on_progress(IdleEvent())
unsub() unsub()
@ -614,10 +645,20 @@ async def backup_addon_before_update(
else: else:
password = None 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: try:
await backup_manager.async_create_backup( await backup_manager.async_create_backup(
agent_ids=[await _default_agent(client)], agent_ids=[await _default_agent(client)],
extra_metadata={"supervisor.addon_update": addon}, extra_metadata={TAG_ADDON_UPDATE: addon},
include_addons=[addon], include_addons=[addon],
include_all_addons=False, include_all_addons=False,
include_database=False, include_database=False,
@ -628,6 +669,14 @@ async def backup_addon_before_update(
) )
except BackupManagerError as err: except BackupManagerError as err:
raise HomeAssistantError(f"Error creating backup: {err}") from 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: async def backup_core_before_update(hass: HomeAssistant) -> None:

View File

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

View File

@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue, async_delete_issue,
) )
from .common import setup_home_connect_entry
from .const import ( from .const import (
BSH_DOOR_STATE_CLOSED, BSH_DOOR_STATE_CLOSED,
BSH_DOOR_STATE_LOCKED, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect binary sensor.""" """Set up the Home Connect binary sensor."""
setup_home_connect_entry(
entities: list[BinarySensorEntity] = [] entry,
for appliance in entry.runtime_data.data.values(): _get_entities_for_appliance,
entities.extend( async_add_entities,
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)
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity): 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 import asyncio
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass, field from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any
@ -25,11 +25,12 @@ from aiohomeconnect.model.error import (
HomeConnectError, HomeConnectError,
HomeConnectRequestError, HomeConnectRequestError,
) )
from aiohomeconnect.model.program import EnumerateAvailableProgram from aiohomeconnect.model.program import EnumerateProgram
from propcache.api import cached_property from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
@ -46,9 +47,9 @@ EVENT_STREAM_RECONNECT_DELAY = 30
class HomeConnectApplianceData: class HomeConnectApplianceData:
"""Class to hold Home Connect appliance data.""" """Class to hold Home Connect appliance data."""
events: dict[EventKey, Event] = field(default_factory=dict) events: dict[EventKey, Event]
info: HomeAppliance info: HomeAppliance
programs: list[EnumerateAvailableProgram] = field(default_factory=list) programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting] settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status] status: dict[StatusKey, Status]
@ -83,6 +84,10 @@ class HomeConnectCoordinator(
name=config_entry.entry_id, name=config_entry.entry_id,
) )
self.client = client self.client = client
self._special_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
@cached_property @cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@ -107,6 +112,28 @@ class HomeConnectCoordinator(
return remove_listener_and_invalidate_context_listeners 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 @callback
def start_event_listener(self) -> None: def start_event_listener(self) -> None:
"""Start event listener.""" """Start event listener."""
@ -121,9 +148,10 @@ class HomeConnectCoordinator(
while True: while True:
try: try:
async for event_message in self.client.stream_all_events(): async for event_message in self.client.stream_all_events():
event_message_ha_id = event_message.ha_id
match event_message.type: match event_message.type:
case EventType.STATUS: 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: for event in event_message.data.items:
status_key = StatusKey(event.key) status_key = StatusKey(event.key)
if status_key in statuses: if status_key in statuses:
@ -134,10 +162,11 @@ class HomeConnectCoordinator(
raw_key=status_key.value, raw_key=status_key.value,
value=event.value, value=event.value,
) )
self._call_event_listener(event_message)
case EventType.NOTIFY: case EventType.NOTIFY:
settings = self.data[event_message.ha_id].settings settings = self.data[event_message_ha_id].settings
events = self.data[event_message.ha_id].events events = self.data[event_message_ha_id].events
for event in event_message.data.items: for event in event_message.data.items:
if event.key in SettingKey: if event.key in SettingKey:
setting_key = SettingKey(event.key) setting_key = SettingKey(event.key)
@ -151,13 +180,56 @@ class HomeConnectCoordinator(
) )
else: else:
events[event.key] = event events[event.key] = event
self._call_event_listener(event_message)
case EventType.EVENT: 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: for event in event_message.data.items:
events[event.key] = event 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: except (EventStreamInterruptedError, HomeConnectRequestError) as error:
_LOGGER.debug( _LOGGER.debug(
@ -186,6 +258,12 @@ class HomeConnectCoordinator(
): ):
listener() 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]: async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
"""Fetch data from Home Connect.""" """Fetch data from Home Connect."""
try: try:
@ -197,62 +275,101 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error), translation_placeholders=get_dict_from_home_connect_error(error),
) from error ) from error
appliances_data = self.data or {} return {
for appliance in appliances.homeappliances: appliance.ha_id: await self._get_appliance_data(
try: appliance, self.data.get(appliance.ha_id) if self.data else None
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
) )
if appliance.ha_id in appliances_data: for appliance in appliances.homeappliances
appliances_data[appliance.ha_id].update(appliance_data) }
appliance_data = appliances_data[appliance.ha_id]
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: else:
appliances_data[appliance.ha_id] = appliance_data programs.extend(all_programs.programs)
if ( for program, event_key in (
appliance.type in APPLIANCES_WITH_PROGRAMS (
and not appliance_data.programs all_programs.active,
): EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
try: ),
appliance_data.programs.extend( (
( all_programs.selected,
await self.client.get_available_programs(appliance.ha_id) EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
).programs ),
) ):
except HomeConnectError as error: if program and program.key:
_LOGGER.debug( events[event_key] = Event(
"Error fetching programs for %s: %s", event_key,
appliance.ha_id, event_key.value,
error 0,
if isinstance(error, HomeConnectApiError) "",
else type(error).__name__, "",
) program.key,
return appliances_data )
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_unique_id = f"{appliance.info.ha_id}-{desc.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.info.ha_id)}, identifiers={(DOMAIN, appliance.info.ha_id)},
manufacturer=appliance.info.brand,
model=appliance.info.vib,
name=appliance.info.name,
) )
self.update_native_value() self.update_native_value()
@ -56,3 +53,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def bsh_key(self) -> str: def bsh_key(self) -> str:
"""Return the BSH key.""" """Return the BSH key."""
return self.entity_description.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.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util from homeassistant.util import color as color_util
from .common import setup_home_connect_entry
from .const import ( from .const import (
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR, BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
DOMAIN, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect light.""" """Set up the Home Connect light."""
setup_home_connect_entry(
async_add_entities( entry,
[ _get_entities_for_appliance,
HomeConnectLight(entry.runtime_data, appliance, description) async_add_entities,
for description in LIGHTS
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
) )

View File

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

View File

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

View File

@ -1,15 +1,20 @@
"""Provides a select platform for Home Connect.""" """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 import EventKey, ProgramKey
from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import Execution
from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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 .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
from .coordinator import ( from .coordinator import (
HomeConnectApplianceData, HomeConnectApplianceData,
@ -29,41 +34,80 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() 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 = ( PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
SelectEntityDescription( HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
translation_key="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, key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
translation_key="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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect select entities.""" """Set up the Home Connect select entities."""
setup_home_connect_entry(
async_add_entities( entry,
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) _get_entities_for_appliance,
for appliance in entry.runtime_data.data.values() async_add_entities,
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
) )
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity): class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Select class for Home Connect programs.""" """Select class for Home Connect programs."""
entity_description: HomeConnectProgramSelectEntityDescription
def __init__( def __init__(
self, self,
coordinator: HomeConnectCoordinator, coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData, appliance: HomeConnectApplianceData,
desc: SelectEntityDescription, desc: HomeConnectProgramSelectEntityDescription,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
super().__init__( super().__init__(
@ -75,9 +119,11 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
PROGRAMS_TRANSLATION_KEYS_MAP[program.key] PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
for program in appliance.programs for program in appliance.programs
if program.key != ProgramKey.UNKNOWN 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: def update_native_value(self) -> None:
"""Set the program value.""" """Set the program value."""
@ -92,22 +138,15 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Select new program.""" """Select new program."""
program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option] program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
try: try:
if self.start_on_select: await self.entity_description.set_program_fn(
await self.coordinator.client.start_program( self.coordinator.client,
self.appliance.info.ha_id, program_key=program_key self.appliance.info.ha_id,
) program_key,
else: )
await self.coordinator.client.set_selected_program(
self.appliance.info.ha_id, program_key=program_key
)
except HomeConnectError as err: except HomeConnectError as err:
if self.start_on_select:
translation_key = "start_program"
else:
translation_key = "select_program"
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key=translation_key, translation_key=self.entity_description.error_translation_key,
translation_placeholders={ translation_placeholders={
**get_dict_from_home_connect_error(err), **get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, 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.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify
from .common import setup_home_connect_entry
from .const import ( from .const import (
APPLIANCES_WITH_PROGRAMS, APPLIANCES_WITH_PROGRAMS,
BSH_OPERATION_STATE_FINISHED, BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE, BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN, BSH_OPERATION_STATE_RUN,
) )
from .coordinator import HomeConnectConfigEntry from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
EVENT_OPTIONS = ["confirmed", "off", "present"] 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect sensor.""" """Set up the Home Connect sensor."""
setup_home_connect_entry(
entities: list[SensorEntity] = [] entry,
for appliance in entry.runtime_data.data.values(): _get_entities_for_appliance,
entities.extend( async_add_entities,
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)
class HomeConnectSensor(HomeConnectEntity, SensorEntity): 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 import EventKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError 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.automation import automations_with_entity
from homeassistant.components.script import scripts_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 homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .common import setup_home_connect_entry
from .const import ( from .const import (
BSH_POWER_OFF, BSH_POWER_OFF,
BSH_POWER_ON, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect switch.""" """Set up the Home Connect switch."""
setup_home_connect_entry(
entities: list[SwitchEntity] = [] entry,
for appliance in entry.runtime_data.data.values(): _get_entities_for_appliance,
entities.extend( async_add_entities,
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)
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity): class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
@ -184,7 +195,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self, self,
coordinator: HomeConnectCoordinator, coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData, appliance: HomeConnectApplianceData,
program: EnumerateAvailableProgram, program: EnumerateProgram,
) -> None: ) -> None:
"""Initialize the entity.""" """Initialize the entity."""
desc = " ".join(["Program", program.key.split(".")[-1]]) desc = " ".join(["Program", program.key.split(".")[-1]])
@ -192,6 +203,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
desc = " ".join( desc = " ".join(
["Program", program.key.split(".")[-3], program.key.split(".")[-1]] ["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
) )
self.program = program
super().__init__( super().__init__(
coordinator, coordinator,
appliance, appliance,
@ -200,7 +212,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self._attr_name = f"{appliance.info.name} {desc}" self._attr_name = f"{appliance.info.name} {desc}"
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}" self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
self._attr_has_entity_name = False self._attr_has_entity_name = False
self.program = program
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass.""" """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.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import setup_home_connect_entry
from .const import ( from .const import (
DOMAIN, DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING, SVE_TRANSLATION_KEY_SET_SETTING,
@ -18,7 +19,7 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_KEY, SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE, SVE_TRANSLATION_PLACEHOLDER_VALUE,
) )
from .coordinator import HomeConnectConfigEntry from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback, async_add_entities: AddEntitiesCallback,
) -> None: ) -> None:
"""Set up the Home Connect switch.""" """Set up the Home Connect switch."""
setup_home_connect_entry(
async_add_entities( entry,
[ _get_entities_for_appliance,
HomeConnectTimeEntity(entry.runtime_data, appliance, description) async_add_entities,
for description in TIME_ENTITIES
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
) )

View File

@ -1,6 +1,7 @@
"""Constants for the homee integration.""" """Constants for the homee integration."""
from homeassistant.const import ( from homeassistant.const import (
DEGREE,
LIGHT_LUX, LIGHT_LUX,
PERCENTAGE, PERCENTAGE,
REVOLUTIONS_PER_MINUTE, REVOLUTIONS_PER_MINUTE,
@ -32,6 +33,7 @@ HOMEE_UNIT_TO_HA_UNIT = {
"W": UnitOfPower.WATT, "W": UnitOfPower.WATT,
"m/s": UnitOfSpeed.METERS_PER_SECOND, "m/s": UnitOfSpeed.METERS_PER_SECOND,
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR, "km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
"°": DEGREE,
"°F": UnitOfTemperature.FAHRENHEIT, "°F": UnitOfTemperature.FAHRENHEIT,
"°C": UnitOfTemperature.CELSIUS, "°C": UnitOfTemperature.CELSIUS,
"K": UnitOfTemperature.KELVIN, "K": UnitOfTemperature.KELVIN,
@ -51,7 +53,7 @@ OPEN_CLOSE_MAP_REVERSED = {
0.0: "closed", 0.0: "closed",
1.0: "open", 1.0: "open",
2.0: "partial", 2.0: "partial",
3.0: "cosing", 3.0: "closing",
4.0: "opening", 4.0: "opening",
} }
WINDOW_MAP = { 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.""" """Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = { COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE, NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER, NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
} }
@ -93,6 +94,7 @@ def is_cover_node(node: HomeeNode) -> bool:
return node.profile in [ return node.profile in [
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH, NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION, NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
NodeProfile.ENTRANCE_GATE_OPERATOR,
NodeProfile.GARAGE_DOOR_OPERATOR, NodeProfile.GARAGE_DOOR_OPERATOR,
NodeProfile.SHUTTER_POSITION_SWITCH, NodeProfile.SHUTTER_POSITION_SWITCH,
] ]

View File

@ -2,7 +2,9 @@
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
from pyHomee.model import HomeeAttribute, HomeeNode 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.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
@ -137,7 +139,13 @@ class HomeeNodeEntity(Entity):
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None: async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
"""Set an attribute value on the homee node.""" """Set an attribute value on the homee node."""
homee = self._entry.runtime_data 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: def _on_node_updated(self, node: HomeeNode) -> None:
self.schedule_update_ha_state() self.schedule_update_ha_state()

View File

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

View File

@ -67,7 +67,23 @@
"name": "Link quality" "name": "Link quality"
}, },
"node_state": { "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": { "operating_hours": {
"name": "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_CODEC,
CONF_VIDEO_MAP, CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE, CONF_VIDEO_PACKET_SIZE,
CONF_VIDEO_PROFILE_NAMES,
DEFAULT_AUDIO_CODEC, DEFAULT_AUDIO_CODEC,
DEFAULT_AUDIO_MAP, DEFAULT_AUDIO_MAP,
DEFAULT_AUDIO_PACKET_SIZE, DEFAULT_AUDIO_PACKET_SIZE,
@ -90,6 +91,7 @@ from .const import (
DEFAULT_VIDEO_CODEC, DEFAULT_VIDEO_CODEC,
DEFAULT_VIDEO_MAP, DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE, DEFAULT_VIDEO_PACKET_SIZE,
DEFAULT_VIDEO_PROFILE_NAMES,
DOMAIN, DOMAIN,
FEATURE_ON_OFF, FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE, FEATURE_PLAY_PAUSE,
@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In( vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
VALID_VIDEO_CODECS VALID_VIDEO_CODECS
), ),
vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [
cv.string
],
vol.Optional( vol.Optional(
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
): cv.positive_int, ): cv.positive_int,

View File

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

View File

@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/imap", "documentation": "https://www.home-assistant.io/integrations/imap",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["aioimaplib"], "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={} 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): for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address address = discovery_info.address
if address in current_addresses or address in self._discovered_devices: 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 # Handle ISY precision and rounding
value = convert_isy_value_to_hass(value, uom, self.target.prec) value = convert_isy_value_to_hass(value, uom, self.target.prec)
if value is None:
return None
# Convert temperatures to Home Assistant's unit # Convert temperatures to Home Assistant's unit
if uom in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT): if uom in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT):
value = self.hass.config.units.temperature(value, uom) value = self.hass.config.units.temperature(value, uom)
if value is None:
return None
assert isinstance(value, (int, float)) assert isinstance(value, (int, float))
return value return value

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass 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 pylamarzocco.devices.machine import LaMarzoccoMachine
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -21,7 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import LaMarzoccoConfigEntry from .coordinator import LaMarzoccoConfigEntry, LaMarzoccoUpdateCoordinator
from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity from .entity import LaMarzoccoEntity, LaMarzoccoEntityDescription, LaMarzoccScaleEntity
# Coordinator is used to centralize the data updates # Coordinator is used to centralize the data updates
@ -37,6 +37,15 @@ class LaMarzoccoSensorEntityDescription(
value_fn: Callable[[LaMarzoccoMachine], float | int] 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, ...] = ( ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription( LaMarzoccoSensorEntityDescription(
key="shot_timer", key="shot_timer",
@ -79,7 +88,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription( LaMarzoccoSensorEntityDescription(
key="drink_stats_coffee", key="drink_stats_coffee",
translation_key="drink_stats_coffee", translation_key="drink_stats_coffee",
native_unit_of_measurement="drinks",
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: device.statistics.total_coffee, value_fn=lambda device: device.statistics.total_coffee,
available_fn=lambda device: len(device.statistics.drink_stats) > 0, available_fn=lambda device: len(device.statistics.drink_stats) > 0,
@ -88,7 +96,6 @@ STATISTIC_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription( LaMarzoccoSensorEntityDescription(
key="drink_stats_flushing", key="drink_stats_flushing",
translation_key="drink_stats_flushing", translation_key="drink_stats_flushing",
native_unit_of_measurement="drinks",
state_class=SensorStateClass.TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING,
value_fn=lambda device: device.statistics.total_flushes, value_fn=lambda device: device.statistics.total_flushes,
available_fn=lambda device: len(device.statistics.drink_stats) > 0, 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, ...] = ( SCALE_ENTITIES: tuple[LaMarzoccoSensorEntityDescription, ...] = (
LaMarzoccoSensorEntityDescription( LaMarzoccoSensorEntityDescription(
key="scale_battery", key="scale_battery",
@ -120,6 +139,8 @@ async def async_setup_entry(
"""Set up sensor entities.""" """Set up sensor entities."""
config_coordinator = entry.runtime_data.config_coordinator config_coordinator = entry.runtime_data.config_coordinator
entities: list[LaMarzoccoSensorEntity | LaMarzoccoKeySensorEntity] = []
entities = [ entities = [
LaMarzoccoSensorEntity(config_coordinator, description) LaMarzoccoSensorEntity(config_coordinator, description)
for description in ENTITIES for description in ENTITIES
@ -142,6 +163,14 @@ async def async_setup_entry(
if description.supported_fn(statistics_coordinator) 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: def _async_add_new_scale() -> None:
async_add_entities( async_add_entities(
LaMarzoccoScaleSensorEntity(config_coordinator, description) LaMarzoccoScaleSensorEntity(config_coordinator, description)
@ -159,11 +188,36 @@ class LaMarzoccoSensorEntity(LaMarzoccoEntity, SensorEntity):
entity_description: LaMarzoccoSensorEntityDescription entity_description: LaMarzoccoSensorEntityDescription
@property @property
def native_value(self) -> int | float: def native_value(self) -> int | float | None:
"""State of the sensor.""" """State of the sensor."""
return self.entity_description.value_fn(self.coordinator.device) 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): class LaMarzoccoScaleSensorEntity(LaMarzoccoSensorEntity, LaMarzoccScaleEntity):
"""Sensor for a La Marzocco scale.""" """Sensor for a La Marzocco scale."""

View File

@ -175,10 +175,16 @@
"name": "Current steam temperature" "name": "Current steam temperature"
}, },
"drink_stats_coffee": { "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": { "drink_stats_flushing": {
"name": "Total flushes made" "name": "Total flushes made",
"unit_of_measurement": "flushes"
}, },
"shot_timer": { "shot_timer": {
"name": "Shot timer" "name": "Shot timer"

View File

@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/lcn", "documentation": "https://www.home-assistant.io/integrations/lcn",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pypck"], "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", "documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "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"], "dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/led_ble", "documentation": "https://www.home-assistant.io/integrations/led_ble",
"iot_class": "local_polling", "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 from .coordinator import LetPotDeviceCoordinator
PLATFORMS: list[Platform] = [Platform.TIME] PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.TIME]
type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]] type LetPotConfigEntry = ConfigEntry[list[LetPotDeviceCoordinator]]

View File

@ -1,5 +1,11 @@
"""Base class for LetPot entities.""" """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.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
@ -23,3 +29,27 @@ class LetPotEntity(CoordinatorEntity[LetPotDeviceCoordinator]):
model_id=coordinator.device_client.device_model_code, model_id=coordinator.device_client.device_model_code,
serial_number=coordinator.device.serial_number, 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", "integration_type": "hub",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"quality_scale": "bronze", "quality_scale": "bronze",
"requirements": ["letpot==0.3.0"] "requirements": ["letpot==0.4.0"]
} }

View File

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

View File

@ -32,6 +32,20 @@
} }
}, },
"entity": { "entity": {
"switch": {
"alarm_sound": {
"name": "Alarm sound"
},
"auto_mode": {
"name": "Auto mode"
},
"power": {
"name": "Power"
},
"pump_cycling": {
"name": "Pump cycling"
}
},
"time": { "time": {
"light_schedule_end": { "light_schedule_end": {
"name": "Light off" "name": "Light off"
@ -40,5 +54,13 @@
"name": "Light on" "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 . import LetPotConfigEntry
from .coordinator import LetPotDeviceCoordinator 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 # 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. # 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 the time."""
return self.entity_description.value_fn(self.coordinator.data) return self.entity_description.value_fn(self.coordinator.data)
@exception_handler
async def async_set_value(self, value: time) -> None: async def async_set_value(self, value: time) -> None:
"""Set the time.""" """Set the time."""
await self.entity_description.set_value_fn( 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) saturation = int(saturation / 100 * 65535)
kelvin = 3500 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 # added in 2025.1, can be removed in 2026.1
_LOGGER.warning( _LOGGER.warning(
"The 'color_temp' parameter is deprecated. Please use 'color_temp_kelvin' for" "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