From f3ad6bd9b63cb81683a9394a2d02417c689784bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 17:55:24 +0200 Subject: [PATCH 1/5] Report correctly when no funds for OpenAI (#148677) --- .../components/openai_conversation/entity.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 97f3bd0ccfee9d..db14480ec5f8a9 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -362,19 +362,26 @@ async def _async_handle_chat_log( try: result = await client.responses.create(**model_args) + + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, result, messages) + ): + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) except openai.RateLimitError as err: LOGGER.error("Rate limited by OpenAI: %s", err) raise HomeAssistantError("Rate limited or insufficient funds") from err except openai.OpenAIError as err: + if ( + isinstance(err, openai.APIError) + and err.type == "insufficient_quota" + ): + LOGGER.error("Insufficient funds for OpenAI: %s", err) + raise HomeAssistantError("Insufficient funds for OpenAI") from err + LOGGER.error("Error talking to OpenAI: %s", err) raise HomeAssistantError("Error talking to OpenAI") from err - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) - if not chat_log.unresponded_tool_results: break From 23a8442abec4468d2a5d9658031d8c9a9d6eeb5b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 19:35:11 +0200 Subject: [PATCH 2/5] Make attachments native to chat log (#148693) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/ai_task/__init__.py | 3 +- homeassistant/components/ai_task/entity.py | 4 ++- homeassistant/components/ai_task/task.py | 33 +++++++------------ .../components/conversation/__init__.py | 2 ++ .../components/conversation/chat_log.py | 19 +++++++++++ .../ai_task.py | 2 +- .../entity.py | 11 ++----- .../ai_task/snapshots/test_task.ambr | 1 + tests/components/ai_task/test_init.py | 6 ++-- .../snapshots/test_conversation.ambr | 1 + .../test_ai_task.py | 2 +- .../snapshots/test_conversation.ambr | 2 ++ 12 files changed, 49 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index a472b0db131d9a..a16e11c05d753a 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -33,7 +33,7 @@ ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenDataTask, GenDataTaskResult, PlayMediaWithId, async_generate_data +from .task import GenDataTask, GenDataTaskResult, async_generate_data __all__ = [ "DOMAIN", @@ -41,7 +41,6 @@ "AITaskEntityFeature", "GenDataTask", "GenDataTaskResult", - "PlayMediaWithId", "async_generate_data", "async_setup", "async_setup_entry", diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index cb6094cba4e97f..420777ce5c3a23 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -79,7 +79,9 @@ async def _async_get_ai_task_chat_log( user_llm_prompt=DEFAULT_SYSTEM_PROMPT, ) - chat_log.async_add_user_content(UserContent(task.instructions)) + chat_log.async_add_user_content( + UserContent(task.instructions, attachments=task.attachments) + ) yield chat_log diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 72d1018210c588..bb57a89203ea89 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -2,30 +2,18 @@ from __future__ import annotations -from dataclasses import dataclass, fields +from dataclasses import dataclass from typing import Any import voluptuous as vol -from homeassistant.components import media_source +from homeassistant.components import conversation, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature -@dataclass(slots=True) -class PlayMediaWithId(media_source.PlayMedia): - """Play media with a media content ID.""" - - media_content_id: str - """Media source ID to play.""" - - def __str__(self) -> str: - """Return media source ID as a string.""" - return f"" - - async def async_generate_data( hass: HomeAssistant, *, @@ -52,7 +40,7 @@ async def async_generate_data( ) # Resolve attachments - resolved_attachments: list[PlayMediaWithId] | None = None + resolved_attachments: list[conversation.Attachment] | None = None if attachments: if AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features: @@ -66,13 +54,16 @@ async def async_generate_data( media = await media_source.async_resolve_media( hass, attachment["media_content_id"], None ) + if media.path is None: + raise HomeAssistantError( + "Only local attachments are currently supported" + ) resolved_attachments.append( - PlayMediaWithId( - **{ - field.name: getattr(media, field.name) - for field in fields(media) - }, + conversation.Attachment( media_content_id=attachment["media_content_id"], + url=media.url, + mime_type=media.mime_type, + path=media.path, ) ) @@ -99,7 +90,7 @@ class GenDataTask: structure: vol.Schema | None = None """Optional structure for the data to be generated.""" - attachments: list[PlayMediaWithId] | None = None + attachments: list[conversation.Attachment] | None = None """List of attachments to go along the instructions.""" def __str__(self) -> str: diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index 66a5735e6b6e68..ec866604205594 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -34,6 +34,7 @@ from .chat_log import ( AssistantContent, AssistantContentDeltaDict, + Attachment, ChatLog, Content, ConverseError, @@ -66,6 +67,7 @@ "HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", + "Attachment", "ChatLog", "Content", "ConversationEntity", diff --git a/homeassistant/components/conversation/chat_log.py b/homeassistant/components/conversation/chat_log.py index 6322bdb44350c3..e8ec66afa7653b 100644 --- a/homeassistant/components/conversation/chat_log.py +++ b/homeassistant/components/conversation/chat_log.py @@ -8,6 +8,7 @@ from contextvars import ContextVar from dataclasses import asdict, dataclass, field, replace import logging +from pathlib import Path from typing import Any, Literal, TypedDict import voluptuous as vol @@ -136,6 +137,24 @@ class UserContent: role: Literal["user"] = field(init=False, default="user") content: str + attachments: list[Attachment] | None = field(default=None) + + +@dataclass(frozen=True) +class Attachment: + """Attachment for a chat message.""" + + media_content_id: str + """Media content ID of the attachment.""" + + url: str + """URL of the attachment.""" + + mime_type: str + """MIME type of the attachment.""" + + path: Path + """Path to the attachment on disk.""" @dataclass(frozen=True) diff --git a/homeassistant/components/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py index 80d5a1dfa06adc..4ffca835fed219 100644 --- a/homeassistant/components/google_generative_ai_conversation/ai_task.py +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -48,7 +48,7 @@ async def _async_generate_data( chat_log: conversation.ChatLog, ) -> ai_task.GenDataTaskResult: """Handle a generate data task.""" - await self._async_handle_chat_log(chat_log, task.structure, task.attachments) + await self._async_handle_chat_log(chat_log, task.structure) if not isinstance(chat_log.content[-1], conversation.AssistantContent): LOGGER.error( diff --git a/homeassistant/components/google_generative_ai_conversation/entity.py b/homeassistant/components/google_generative_ai_conversation/entity.py index fce1fdd40e7d76..8e967d8451793e 100644 --- a/homeassistant/components/google_generative_ai_conversation/entity.py +++ b/homeassistant/components/google_generative_ai_conversation/entity.py @@ -30,7 +30,7 @@ import voluptuous as vol from voluptuous_openapi import convert -from homeassistant.components import ai_task, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -338,7 +338,6 @@ async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, structure: vol.Schema | None = None, - attachments: list[ai_task.PlayMediaWithId] | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -442,15 +441,11 @@ async def _async_handle_chat_log( user_message = chat_log.content[-1] assert isinstance(user_message, conversation.UserContent) chat_request: str | list[Part] = user_message.content - if attachments: - if any(a.path is None for a in attachments): - raise HomeAssistantError( - "Only local attachments are currently supported" - ) + if user_message.attachments: files = await async_prepare_files_for_prompt( self.hass, self._genai_client, - [a.path for a in attachments], # type: ignore[misc] + [a.path for a in user_message.attachments], ) chat_request = [chat_request, *files] diff --git a/tests/components/ai_task/snapshots/test_task.ambr b/tests/components/ai_task/snapshots/test_task.ambr index 3b40b0632a62a7..181fc383d6472f 100644 --- a/tests/components/ai_task/snapshots/test_task.ambr +++ b/tests/components/ai_task/snapshots/test_task.ambr @@ -9,6 +9,7 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Test prompt', 'role': 'user', }), diff --git a/tests/components/ai_task/test_init.py b/tests/components/ai_task/test_init.py index 840285493ac87d..19f73045532019 100644 --- a/tests/components/ai_task/test_init.py +++ b/tests/components/ai_task/test_init.py @@ -1,5 +1,6 @@ """Test initialization of the AI Task component.""" +from pathlib import Path from typing import Any from unittest.mock import patch @@ -89,6 +90,7 @@ async def test_generate_data_service( return_value=media_source.PlayMedia( url="http://example.com/media.mp4", mime_type="video/mp4", + path=Path("media.mp4"), ), ): result = await hass.services.async_call( @@ -118,9 +120,7 @@ async def test_generate_data_service( assert attachment.url == "http://example.com/media.mp4" assert attachment.mime_type == "video/mp4" assert attachment.media_content_id == msg_attachment["media_content_id"] - assert ( - str(attachment) == f"" - ) + assert attachment.path == Path("media.mp4") async def test_generate_data_service_structure_fields( diff --git a/tests/components/anthropic/snapshots/test_conversation.ambr b/tests/components/anthropic/snapshots/test_conversation.ambr index 09618b135db6f7..d97eaab41e469d 100644 --- a/tests/components/anthropic/snapshots/test_conversation.ambr +++ b/tests/components/anthropic/snapshots/test_conversation.ambr @@ -12,6 +12,7 @@ 'role': 'system', }), dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py index 653b41fcb6e780..6326bd94ad9b92 100644 --- a/tests/components/google_generative_ai_conversation/test_ai_task.py +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -185,7 +185,7 @@ async def test_generate_data( ) assert result.data == {"characters": ["Mario", "Luigi"]} - assert len(mock_chat_create.mock_calls) == 4 + assert len(mock_chat_create.mock_calls) == 3 config = mock_chat_create.mock_calls[-1][2]["config"] assert config.response_mime_type == "application/json" assert config.response_schema == { diff --git a/tests/components/openai_conversation/snapshots/test_conversation.ambr b/tests/components/openai_conversation/snapshots/test_conversation.ambr index 48ad0878b2faae..77c52ab97e6eeb 100644 --- a/tests/components/openai_conversation/snapshots/test_conversation.ambr +++ b/tests/components/openai_conversation/snapshots/test_conversation.ambr @@ -2,6 +2,7 @@ # name: test_function_call list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), @@ -58,6 +59,7 @@ # name: test_function_call_without_reasoning list([ dict({ + 'attachments': None, 'content': 'Please call the test function', 'role': 'user', }), From 611f86cf8c1a89b33baeff902563476dbdea564a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 13 Jul 2025 21:51:46 +0200 Subject: [PATCH 3/5] OpenAI: Add attachment support to AI task (#148676) --- .../components/openai_conversation/ai_task.py | 5 +- .../components/openai_conversation/entity.py | 20 +++++ .../openai_conversation/test_ai_task.py | 88 ++++++++++++++++++- 3 files changed, 110 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py index ff8c6e62520b86..5fc700a73ad7e5 100644 --- a/homeassistant/components/openai_conversation/ai_task.py +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -39,7 +39,10 @@ class OpenAITaskEntity( ): """OpenAI AI Task entity.""" - _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + _attr_supported_features = ( + ai_task.AITaskEntityFeature.GENERATE_DATA + | ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS + ) async def _async_generate_data( self, diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index db14480ec5f8a9..7679bef83f13af 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -345,6 +345,26 @@ async def _async_handle_chat_log( for content in chat_log.content for m in _convert_content_to_param(content) ] + + last_content = chat_log.content[-1] + + # Handle attachments by adding them to the last user message + if last_content.role == "user" and last_content.attachments: + files = await async_prepare_files_for_prompt( + self.hass, + [a.path for a in last_content.attachments], + ) + last_message = messages[-1] + assert ( + last_message["type"] == "message" + and last_message["role"] == "user" + and isinstance(last_message["content"], str) + ) + last_message["content"] = [ + {"type": "input_text", "text": last_message["content"]}, # type: ignore[list-item] + *files, # type: ignore[list-item] + ] + if structure and structure_name: model_args["text"] = { "format": { diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 4541e11f5f8b76..14e3056c0e221f 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -1,11 +1,12 @@ """Test AI Task platform of OpenAI Conversation integration.""" -from unittest.mock import AsyncMock +from pathlib import Path +from unittest.mock import AsyncMock, patch import pytest import voluptuous as vol -from homeassistant.components import ai_task +from homeassistant.components import ai_task, media_source from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, selector @@ -122,3 +123,86 @@ async def test_generate_invalid_structured_data( }, ), ) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data_with_attachments( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation with attachments.""" + entity_id = "ai_task.openai_ai_task" + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="Hi there!", output_index=0) + ] + + # Test with attachments + with ( + patch( + "homeassistant.components.media_source.async_resolve_media", + side_effect=[ + media_source.PlayMedia( + url="http://example.com/doorbell_snapshot.jpg", + mime_type="image/jpeg", + path=Path("doorbell_snapshot.jpg"), + ), + media_source.PlayMedia( + url="http://example.com/context.txt", + mime_type="text/plain", + path=Path("context.txt"), + ), + ], + ), + patch("pathlib.Path.exists", return_value=True), + # patch.object(hass.config, "is_allowed_path", return_value=True), + patch( + "homeassistant.components.openai_conversation.entity.guess_file_type", + return_value=("image/jpeg", None), + ), + patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"), + ): + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + attachments=[ + {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, + {"media_content_id": "media-source://media/context.txt"}, + ], + ) + + assert result.data == "Hi there!" + + # Verify that the create stream was called with the correct parameters + # The last call should have the user message with attachments + call_args = mock_create_stream.call_args + assert call_args is not None + + # Check that the input includes the attachments + input_messages = call_args[1]["input"] + assert len(input_messages) > 0 + + # Find the user message with attachments + user_message_with_attachments = input_messages[-2] + + assert user_message_with_attachments is not None + assert isinstance(user_message_with_attachments["content"], list) + assert len(user_message_with_attachments["content"]) == 3 # Text + attachments + assert user_message_with_attachments["content"] == [ + {"type": "input_text", "text": "Test prompt"}, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + { + "detail": "auto", + "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_image", + }, + ] From b2fe17c6d47a09d84ea21ebe048bdab63417feb0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 13 Jul 2025 22:12:00 +0200 Subject: [PATCH 4/5] Update PyMicroBot to 0.0.23 (#148700) --- .../components/keymitt_ble/__init__.py | 32 ++----------------- .../components/keymitt_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/licenses.py | 1 - 5 files changed, 5 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/keymitt_ble/__init__.py b/homeassistant/components/keymitt_ble/__init__.py index 0f71519e420fd7..01948006852851 100644 --- a/homeassistant/components/keymitt_ble/__init__.py +++ b/homeassistant/components/keymitt_ble/__init__.py @@ -2,42 +2,14 @@ from __future__ import annotations -from collections.abc import Generator -from contextlib import contextmanager - -import bleak +from microbot import MicroBotApiClient from homeassistant.components import bluetooth from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady - -@contextmanager -def patch_unused_bleak_discover_import() -> Generator[None]: - """Patch bleak.discover import in microbot. It is unused and was removed in bleak 1.0.0.""" - - def getattr_bleak(name: str) -> object: - if name == "discover": - return None - raise AttributeError - - original_func = bleak.__dict__.get("__getattr__") - bleak.__dict__["__getattr__"] = getattr_bleak - try: - yield - finally: - if original_func is not None: - bleak.__dict__["__getattr__"] = original_func - - -with patch_unused_bleak_discover_import(): - from microbot import MicroBotApiClient - -from .coordinator import ( # noqa: E402 - MicroBotConfigEntry, - MicroBotDataUpdateCoordinator, -) +from .coordinator import MicroBotConfigEntry, MicroBotDataUpdateCoordinator PLATFORMS: list[str] = [Platform.SWITCH] diff --git a/homeassistant/components/keymitt_ble/manifest.json b/homeassistant/components/keymitt_ble/manifest.json index 5abdfe5b4a79d5..249bb5eb121bd7 100644 --- a/homeassistant/components/keymitt_ble/manifest.json +++ b/homeassistant/components/keymitt_ble/manifest.json @@ -16,5 +16,5 @@ "integration_type": "hub", "iot_class": "assumed_state", "loggers": ["keymitt_ble"], - "requirements": ["PyMicroBot==0.0.17"] + "requirements": ["PyMicroBot==0.0.23"] } diff --git a/requirements_all.txt b/requirements_all.txt index 72e86bc3324d81..5b9322b39abf0f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -70,7 +70,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a846910eb3656..a079b52ce170b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -67,7 +67,7 @@ PyMetEireann==2024.11.0 PyMetno==0.13.0 # homeassistant.components.keymitt_ble -PyMicroBot==0.0.17 +PyMicroBot==0.0.23 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/script/licenses.py b/script/licenses.py index 6d5f7e58f2f5c2..d7819cba5362fd 100644 --- a/script/licenses.py +++ b/script/licenses.py @@ -178,7 +178,6 @@ def from_dict(cls, data: PackageMetadata) -> PackageDefinition: } EXCEPTIONS = { - "PyMicroBot", # https://github.com/spycle/pyMicroBot/pull/3 "PySwitchmate", # https://github.com/Danielhiversen/pySwitchmate/pull/16 "PyXiaomiGateway", # https://github.com/Danielhiversen/PyXiaomiGateway/pull/201 "chacha20poly1305", # LGPL From 74288a3bc8a63061fa7a0c5ccbedd3e489052564 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sun, 13 Jul 2025 22:46:42 +0200 Subject: [PATCH 5/5] Re-enable Home Connect updates automatically (#148657) Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 46 ++++++------ .../components/home_connect/strings.json | 11 --- .../home_connect/test_coordinator.py | 74 +++++++++++-------- 3 files changed, 67 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index bb419f6bd7cd0f..81f785b55aeacf 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -38,7 +38,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, issue_registry as ir +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( @@ -626,39 +626,37 @@ def refreshed_too_often_recently(self, appliance_ha_id: str) -> bool: """Check if the appliance data hasn't been refreshed too often recently.""" now = self.hass.loop.time() - if len(self._execution_tracker[appliance_ha_id]) >= MAX_EXECUTIONS: - return True + + execution_tracker = self._execution_tracker[appliance_ha_id] + initial_len = len(execution_tracker) execution_tracker = self._execution_tracker[appliance_ha_id] = [ timestamp - for timestamp in self._execution_tracker[appliance_ha_id] + for timestamp in execution_tracker if now - timestamp < MAX_EXECUTIONS_TIME_WINDOW ] execution_tracker.append(now) if len(execution_tracker) >= MAX_EXECUTIONS: - ir.async_create_issue( - self.hass, - DOMAIN, - f"home_connect_too_many_connected_paired_events_{appliance_ha_id}", - is_fixable=True, - is_persistent=True, - severity=ir.IssueSeverity.ERROR, - translation_key="home_connect_too_many_connected_paired_events", - data={ - "entry_id": self.config_entry.entry_id, - "appliance_ha_id": appliance_ha_id, - }, - translation_placeholders={ - "appliance_name": self.data[appliance_ha_id].info.name, - "times": str(MAX_EXECUTIONS), - "time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60), - "home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/", - "home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299", - }, - ) + if initial_len < MAX_EXECUTIONS: + _LOGGER.warning( + 'Too many connected/paired events for appliance "%s" ' + "(%s times in less than %s minutes), updates have been disabled " + "and they will be enabled again whenever the connection stabilizes. " + "Consider trying to unplug the appliance " + "for a while to perform a soft reset", + self.data[appliance_ha_id].info.name, + MAX_EXECUTIONS, + MAX_EXECUTIONS_TIME_WINDOW // 60, + ) return True + if initial_len >= MAX_EXECUTIONS: + _LOGGER.info( + 'Connected/paired events from the appliance "%s" have stabilized,' + " updates have been re-enabled", + self.data[appliance_ha_id].info.name, + ) return False diff --git a/homeassistant/components/home_connect/strings.json b/homeassistant/components/home_connect/strings.json index e1c0b42ca0bfd0..853d2bd2f8e53b 100644 --- a/homeassistant/components/home_connect/strings.json +++ b/homeassistant/components/home_connect/strings.json @@ -124,17 +124,6 @@ } }, "issues": { - "home_connect_too_many_connected_paired_events": { - "title": "{appliance_name} sent too many connected or paired events", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]", - "description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})." - } - } - } - }, "deprecated_time_alarm_clock_in_automations_scripts": { "title": "Deprecated alarm clock entity detected in some automations or scripts", "fix_flow": { diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index f9fed995b89b6d..a368cfbef2dc65 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -2,7 +2,6 @@ from collections.abc import Awaitable, Callable from datetime import timedelta -from http import HTTPStatus from typing import Any, cast from unittest.mock import AsyncMock, MagicMock, patch @@ -53,16 +52,11 @@ HomeAssistant, callback, ) -from homeassistant.helpers import ( - device_registry as dr, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator INITIAL_FETCH_CLIENT_METHODS = [ "get_settings", @@ -580,8 +574,7 @@ async def test_paired_disconnected_devices_not_fetching( async def test_coordinator_disabling_updates_for_appliance( hass: HomeAssistant, - hass_client: ClientSessionGenerator, - issue_registry: ir.IssueRegistry, + freezer: FrozenDateTimeFactory, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -592,7 +585,6 @@ async def test_coordinator_disabling_updates_for_appliance( When the user confirms the issue the updates should be enabled again. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -606,13 +598,26 @@ async def test_coordinator_disabling_updates_for_appliance( EventType.CONNECTED, data=ArrayOfEvents([]), ) - for _ in range(8) + for _ in range(6) + ] + ) + await hass.async_block_till_done() + + freezer.tick(timedelta(minutes=10)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(2) ] ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue + # At this point, the updates have been blocked because + # 6 + 2 connected events have been received in less than an hour get_settings_original_side_effect = client.get_settings.side_effect @@ -644,18 +649,36 @@ async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: assert hass.states.is_state("switch.dishwasher_power", STATE_ON) - _client = await hass_client() - resp = await _client.post( - "/api/repairs/issues/fix", - json={"handler": DOMAIN, "issue_id": issue.issue_id}, + # After 55 minutes, the updates should be enabled again + # because one hour has passed since the first connect events, + # so there are 2 connected events in the execution_tracker + freezer.tick(timedelta(minutes=55)) + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + ] ) - assert resp.status == HTTPStatus.OK - flow_id = (await resp.json())["flow_id"] - resp = await _client.post(f"/api/repairs/issues/fix/{flow_id}") - assert resp.status == HTTPStatus.OK + await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert hass.states.is_state("switch.dishwasher_power", STATE_OFF) + # If more connect events are sent, it should be blocked again + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + data=ArrayOfEvents([]), + ) + for _ in range(5) # 2 + 1 + 5 = 8 connect events in less than an hour + ] + ) + await hass.async_block_till_done() + client.get_settings = get_settings_original_side_effect await client.add_events( [ EventMessage( @@ -672,7 +695,6 @@ async def get_settings_side_effect(ha_id: str) -> ArrayOfSettings: async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_reload( hass: HomeAssistant, - issue_registry: ir.IssueRegistry, client: MagicMock, config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], @@ -682,7 +704,6 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r The repair issue should also be deleted. """ appliance_ha_id = "SIEMENS-HCS02DWH1-6BE58C26DCC1" - issue_id = f"home_connect_too_many_connected_paired_events_{appliance_ha_id}" assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED @@ -701,14 +722,9 @@ async def test_coordinator_disabling_updates_for_appliance_is_gone_after_entry_r ) await hass.async_block_till_done() - issue = issue_registry.async_get_issue(DOMAIN, issue_id) - assert issue - await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() - assert not issue_registry.async_get_issue(DOMAIN, issue_id) - assert await integration_setup(client) assert config_entry.state is ConfigEntryState.LOADED