From d15baf9f9fdadd8b5d45da4f1d4e16cffed85361 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 10 Jul 2025 08:30:54 -0700 Subject: [PATCH 1/5] Drop homeassistant agent and assist_pipeline migration code (#147968) Co-authored-by: Franck Nijhof Co-authored-by: Paulus Schoutsen --- .../components/assist_pipeline/__init__.py | 4 - .../components/assist_pipeline/const.py | 1 - .../components/assist_pipeline/pipeline.py | 47 +------ .../components/conversation/__init__.py | 11 -- .../components/conversation/agent_manager.py | 9 +- .../components/conversation/const.py | 1 - .../conversation.py | 5 +- .../components/ollama/conversation.py | 5 +- .../openai_conversation/conversation.py | 5 +- script/hassfest/dependencies.py | 2 - tests/components/assist_pipeline/test_init.py | 8 +- .../assist_pipeline/test_pipeline.py | 52 +------- .../conversation/snapshots/test_http.ambr | 31 ----- .../conversation/snapshots/test_init.ambr | 124 ------------------ tests/components/conversation/test_http.py | 9 +- tests/components/conversation/test_init.py | 13 +- 16 files changed, 26 insertions(+), 301 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 59bd987d90e047..8f4c6efd355dd3 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -38,8 +38,6 @@ async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, - async_run_migrations, async_setup_pipeline_store, async_update_pipeline, ) @@ -61,7 +59,6 @@ "WakeWordSettings", "async_create_default_pipeline", "async_get_pipelines", - "async_migrate_engine", "async_pipeline_from_audio_stream", "async_setup", "async_update_pipeline", @@ -87,7 +84,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DATA_LAST_WAKE_UP] = {} await async_setup_pipeline_store(hass) - await async_run_migrations(hass) async_register_websocket_api(hass) return True diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index 300cb5aad2aa62..52583cf21a40b5 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -3,7 +3,6 @@ DOMAIN = "assist_pipeline" DATA_CONFIG = f"{DOMAIN}.config" -DATA_MIGRATIONS = f"{DOMAIN}_migrations" DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index a1b6ea53445146..0cd593e9666ca1 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -13,7 +13,7 @@ from queue import Empty, Queue from threading import Thread import time -from typing import TYPE_CHECKING, Any, Literal, cast +from typing import TYPE_CHECKING, Any, cast import wave import hass_nabucasa @@ -49,7 +49,6 @@ CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, - DATA_MIGRATIONS, DOMAIN, MS_PER_CHUNK, SAMPLE_CHANNELS, @@ -2059,50 +2058,6 @@ async def async_setup_pipeline_store(hass: HomeAssistant) -> PipelineData: return PipelineData(pipeline_store) -@callback -def async_migrate_engine( - hass: HomeAssistant, - engine_type: Literal["conversation", "stt", "tts", "wake_word"], - old_value: str, - new_value: str, -) -> None: - """Register a migration of an engine used in pipelines.""" - hass.data.setdefault(DATA_MIGRATIONS, {})[engine_type] = (old_value, new_value) - - # Run migrations when config is already loaded - if DATA_CONFIG in hass.data: - hass.async_create_background_task( - async_run_migrations(hass), "assist_pipeline_migration", eager_start=True - ) - - -async def async_run_migrations(hass: HomeAssistant) -> None: - """Run pipeline migrations.""" - if not (migrations := hass.data.get(DATA_MIGRATIONS)): - return - - engine_attr = { - "conversation": "conversation_engine", - "stt": "stt_engine", - "tts": "tts_engine", - "wake_word": "wake_word_entity", - } - - updates = [] - - for pipeline in async_get_pipelines(hass): - attr_updates = {} - for engine_type, (old_value, new_value) in migrations.items(): - if getattr(pipeline, engine_attr[engine_type]) == old_value: - attr_updates[engine_attr[engine_type]] = new_value - - if attr_updates: - updates.append((pipeline, attr_updates)) - - for pipeline, attr_updates in updates: - await async_update_pipeline(hass, pipeline, **attr_updates) - - @dataclass class PipelineConversationData: """Hold data for the duration of a conversation.""" diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py index cf62704b34de99..66a5735e6b6e68 100644 --- a/homeassistant/components/conversation/__init__.py +++ b/homeassistant/components/conversation/__init__.py @@ -51,7 +51,6 @@ DATA_DEFAULT_ENTITY, DOMAIN, HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, SERVICE_PROCESS, SERVICE_RELOAD, ConversationEntityFeature, @@ -65,7 +64,6 @@ __all__ = [ "DOMAIN", "HOME_ASSISTANT_AGENT", - "OLD_HOME_ASSISTANT_AGENT", "AssistantContent", "AssistantContentDeltaDict", "ChatLog", @@ -270,15 +268,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, entity_component, config.get(DOMAIN, {}).get("intents", {}) ) - # Temporary migration. We can remove this in 2024.10 - from homeassistant.components.assist_pipeline import ( # noqa: PLC0415 - async_migrate_engine, - ) - - async_migrate_engine( - hass, "conversation", OLD_HOME_ASSISTANT_AGENT, HOME_ASSISTANT_AGENT - ) - async def handle_process(service: ServiceCall) -> ServiceResponse: """Parse text into commands.""" text = service.data[ATTR_TEXT] diff --git a/homeassistant/components/conversation/agent_manager.py b/homeassistant/components/conversation/agent_manager.py index 38c0ca8db6b486..6203525ac01af5 100644 --- a/homeassistant/components/conversation/agent_manager.py +++ b/homeassistant/components/conversation/agent_manager.py @@ -12,12 +12,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, intent, singleton -from .const import ( - DATA_COMPONENT, - DATA_DEFAULT_ENTITY, - HOME_ASSISTANT_AGENT, - OLD_HOME_ASSISTANT_AGENT, -) +from .const import DATA_COMPONENT, DATA_DEFAULT_ENTITY, HOME_ASSISTANT_AGENT from .entity import ConversationEntity from .models import ( AbstractConversationAgent, @@ -54,7 +49,7 @@ def async_get_agent( hass: HomeAssistant, agent_id: str | None = None ) -> AbstractConversationAgent | ConversationEntity | None: """Get specified agent.""" - if agent_id is None or agent_id in (HOME_ASSISTANT_AGENT, OLD_HOME_ASSISTANT_AGENT): + if agent_id is None or agent_id == HOME_ASSISTANT_AGENT: return hass.data[DATA_DEFAULT_ENTITY] if "." in agent_id: diff --git a/homeassistant/components/conversation/const.py b/homeassistant/components/conversation/const.py index 619a41fd002a77..266a9f15b8316a 100644 --- a/homeassistant/components/conversation/const.py +++ b/homeassistant/components/conversation/const.py @@ -16,7 +16,6 @@ DOMAIN = "conversation" DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"} HOME_ASSISTANT_AGENT = "conversation.home_assistant" -OLD_HOME_ASSISTANT_AGENT = "homeassistant" ATTR_TEXT = "text" ATTR_LANGUAGE = "language" diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index 0b24e8bbc38002..3525fba3af5d60 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -4,7 +4,7 @@ from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -57,9 +57,6 @@ def supported_languages(self) -> list[str] | Literal["*"]: async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index f151f8524a09c8..e0b64702cb4a14 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -4,7 +4,7 @@ from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -52,9 +52,6 @@ def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index 1ec17163f69df4..25e89577ef3ce7 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -2,7 +2,7 @@ from typing import Literal -from homeassistant.components import assist_pipeline, conversation +from homeassistant.components import conversation from homeassistant.config_entries import ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant @@ -57,9 +57,6 @@ def supported_languages(self) -> list[str] | Literal["*"]: async def async_added_to_hass(self) -> None: """When entity is added to Home Assistant.""" await super().async_added_to_hass() - assist_pipeline.async_migrate_engine( - self.hass, "conversation", self.entry.entry_id, self.entity_id - ) conversation.async_set_agent(self.hass, self.entry, self) async def async_will_remove_from_hass(self) -> None: diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index ee932280201bdf..447b3ec79b846d 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -140,8 +140,6 @@ def visit_Import(self, node: ast.Import) -> None: ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), "logbook", - # Temporary needed for migration until 2024.10 - ("conversation", "assist_pipeline"), } diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 0294f9953db99b..a6a449bddd46d8 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -12,7 +12,7 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components import assist_pipeline, stt +from homeassistant.components import assist_pipeline, conversation, stt from homeassistant.components.assist_pipeline.const import ( BYTES_PER_CHUNK, CONF_DEBUG_RECORDING_DIR, @@ -116,7 +116,7 @@ async def audio_data(): await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -184,7 +184,7 @@ async def audio_data(): await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", @@ -252,7 +252,7 @@ async def audio_data(): await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "en-US", "language": "en", "name": "test_name", diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 1302925dab9aa9..3a4895440dce4d 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -29,7 +29,6 @@ async_create_default_pipeline, async_get_pipeline, async_get_pipelines, - async_migrate_engine, async_update_pipeline, ) from homeassistant.const import MATCH_ALL @@ -162,12 +161,6 @@ async def test_loading_pipelines_from_storage( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: """Test loading stored pipelines on start.""" - async_migrate_engine( - hass, - "conversation", - conversation.OLD_HOME_ASSISTANT_AGENT, - conversation.HOME_ASSISTANT_AGENT, - ) id_1 = "01GX8ZWBAQYWNB1XV3EXEZ75DY" hass_storage[STORAGE_KEY] = { "version": STORAGE_VERSION, @@ -176,7 +169,7 @@ async def test_loading_pipelines_from_storage( "data": { "items": [ { - "conversation_engine": conversation.OLD_HOME_ASSISTANT_AGENT, + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": "language_1", "id": id_1, "language": "language_1", @@ -668,43 +661,6 @@ async def test_update_pipeline( } -@pytest.mark.usefixtures("init_supporting_components") -async def test_migrate_after_load(hass: HomeAssistant) -> None: - """Test migrating an engine after done loading.""" - assert await async_setup_component(hass, "assist_pipeline", {}) - - pipeline_data: PipelineData = hass.data[DOMAIN] - store = pipeline_data.pipeline_store - assert len(store.data) == 1 - - assert ( - await async_create_default_pipeline( - hass, - stt_engine_id="bla", - tts_engine_id="bla", - pipeline_name="Bla pipeline", - ) - is None - ) - pipeline = await async_create_default_pipeline( - hass, - stt_engine_id="test", - tts_engine_id="test", - pipeline_name="Test pipeline", - ) - assert pipeline is not None - - async_migrate_engine(hass, "stt", "test", "stt.test") - async_migrate_engine(hass, "tts", "test", "tts.test") - - await hass.async_block_till_done(wait_background_tasks=True) - - pipeline_updated = async_get_pipeline(hass, pipeline.id) - - assert pipeline_updated.stt_engine == "stt.test" - assert pipeline_updated.tts_engine == "tts.test" - - def test_fallback_intent_filter() -> None: """Test that we filter the right things.""" assert ( @@ -1364,7 +1320,7 @@ async def test_stt_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1440,7 +1396,7 @@ async def test_tts_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", @@ -1516,7 +1472,7 @@ async def test_pipeline_language_used_instead_of_conversation_language( await client.send_json_auto_id( { "type": "assist_pipeline/pipeline/create", - "conversation_engine": "homeassistant", + "conversation_engine": conversation.HOME_ASSISTANT_AGENT, "conversation_language": MATCH_ALL, "language": "en", "name": "test_name", diff --git a/tests/components/conversation/snapshots/test_http.ambr b/tests/components/conversation/snapshots/test_http.ambr index 5179409deb0610..391fb609d657a9 100644 --- a/tests/components/conversation/snapshots/test_http.ambr +++ b/tests/components/conversation/snapshots/test_http.ambr @@ -327,37 +327,6 @@ }), }) # --- -# name: test_http_processing_intent[homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': 'entity', - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_ws_api[payload0] dict({ 'continue_conversation': False, diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index a853faa7a3d25c..779bb256180f8f 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -108,37 +108,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[None-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -201,37 +170,6 @@ }), }) # --- -# name: test_turn_on_intent[None-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn kitchen on-None] dict({ 'continue_conversation': False, @@ -294,37 +232,6 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn kitchen on-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- # name: test_turn_on_intent[my_new_conversation-turn on kitchen-None] dict({ 'continue_conversation': False, @@ -387,34 +294,3 @@ }), }) # --- -# name: test_turn_on_intent[my_new_conversation-turn on kitchen-homeassistant] - dict({ - 'continue_conversation': False, - 'conversation_id': , - 'response': dict({ - 'card': dict({ - }), - 'data': dict({ - 'failed': list([ - ]), - 'success': list([ - dict({ - 'id': 'light.kitchen', - 'name': 'kitchen', - 'type': , - }), - ]), - 'targets': list([ - ]), - }), - 'language': 'en', - 'response_type': 'action_done', - 'speech': dict({ - 'plain': dict({ - 'extra_data': None, - 'speech': 'Turned on the light', - }), - }), - }), - }) -# --- diff --git a/tests/components/conversation/test_http.py b/tests/components/conversation/test_http.py index 77fa97ad8452b5..29cd567e904116 100644 --- a/tests/components/conversation/test_http.py +++ b/tests/components/conversation/test_http.py @@ -8,7 +8,10 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.conversation import default_agent -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant @@ -22,8 +25,6 @@ AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -187,7 +188,7 @@ async def test_http_api_wrong_data( }, { "text": "Test Text", - "agent_id": "homeassistant", + "agent_id": HOME_ASSISTANT_AGENT, }, ], ) diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index c3de5f1127cf3a..e757c56042bdae 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -14,7 +14,10 @@ async_handle_sentence_triggers, default_agent, ) -from homeassistant.components.conversation.const import DATA_DEFAULT_ENTITY +from homeassistant.components.conversation.const import ( + DATA_DEFAULT_ENTITY, + HOME_ASSISTANT_AGENT, +) from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -28,8 +31,6 @@ AGENT_ID_OPTIONS = [ None, - # Old value of conversation.HOME_ASSISTANT_AGENT, - "homeassistant", # Current value of conversation.HOME_ASSISTANT_AGENT, "conversation.home_assistant", ] @@ -205,8 +206,8 @@ async def test_get_agent_info( """Test get agent info.""" agent_info = conversation.async_get_agent_info(hass) # Test it's the default - assert conversation.async_get_agent_info(hass, "homeassistant") == agent_info - assert conversation.async_get_agent_info(hass, "homeassistant") == snapshot + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == agent_info + assert conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT) == snapshot assert ( conversation.async_get_agent_info(hass, mock_conversation_agent.agent_id) == snapshot @@ -223,7 +224,7 @@ async def test_get_agent_info( default_agent = conversation.async_get_agent(hass) default_agent._attr_supports_streaming = True assert ( - conversation.async_get_agent_info(hass, "homeassistant").supports_streaming + conversation.async_get_agent_info(hass, HOME_ASSISTANT_AGENT).supports_streaming is True ) From f0a636949af7484f55e83463cbef2060ddfa9285 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:29:48 -0700 Subject: [PATCH 2/5] Support all Energy units in Energy integration (#148566) --- homeassistant/components/energy/sensor.py | 9 ++---- homeassistant/components/energy/validate.py | 19 +++--------- tests/components/energy/test_sensor.py | 6 +++- tests/components/energy/test_validate.py | 34 +++++++++------------ 4 files changed, 26 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 3dc857d75d903d..1105e6f6b864db 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -41,13 +41,8 @@ SensorStateClass.TOTAL, SensorStateClass.TOTAL_INCREASING, } -VALID_ENERGY_UNITS: set[str] = { - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, -} +VALID_ENERGY_UNITS: set[str] = set(UnitOfEnergy) + VALID_ENERGY_UNITS_GAS = { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index 0f46678994f6d1..3590ee9e848766 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -21,14 +21,9 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ) + sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy) } + ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units ) @@ -39,13 +34,9 @@ sensor.SensorDeviceClass.GAS, ) GAS_USAGE_UNITS: dict[str, tuple[UnitOfEnergy | UnitOfVolume, ...]] = { - sensor.SensorDeviceClass.ENERGY: ( - UnitOfEnergy.GIGA_JOULE, - UnitOfEnergy.KILO_WATT_HOUR, - UnitOfEnergy.MEGA_JOULE, - UnitOfEnergy.MEGA_WATT_HOUR, - UnitOfEnergy.WATT_HOUR, - ), + sensor.SensorDeviceClass.ENERGY: ENERGY_USAGE_UNITS[ + sensor.SensorDeviceClass.ENERGY + ], sensor.SensorDeviceClass.GAS: ( UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index a9a249a8498512..b7ccbadbe1cf8f 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -29,6 +29,7 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from homeassistant.util.unit_conversion import _WH_TO_CAL, _WH_TO_J from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from tests.components.recorder.common import async_wait_recording_done @@ -748,10 +749,12 @@ def _compile_statistics(_): @pytest.mark.parametrize( ("energy_unit", "factor"), [ + (UnitOfEnergy.MILLIWATT_HOUR, 1e6), (UnitOfEnergy.WATT_HOUR, 1000), (UnitOfEnergy.KILO_WATT_HOUR, 1), (UnitOfEnergy.MEGA_WATT_HOUR, 0.001), - (UnitOfEnergy.GIGA_JOULE, 0.001 * 3.6), + (UnitOfEnergy.GIGA_JOULE, _WH_TO_J / 1e6), + (UnitOfEnergy.CALORIE, _WH_TO_CAL * 1e3), ], ) async def test_cost_sensor_handle_energy_units( @@ -815,6 +818,7 @@ async def test_cost_sensor_handle_energy_units( @pytest.mark.parametrize( ("price_unit", "factor"), [ + (f"EUR/{UnitOfEnergy.MILLIWATT_HOUR}", 1e-6), (f"EUR/{UnitOfEnergy.WATT_HOUR}", 0.001), (f"EUR/{UnitOfEnergy.KILO_WATT_HOUR}", 1), (f"EUR/{UnitOfEnergy.MEGA_WATT_HOUR}", 1000), diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 6389ac0b372002..9e7a2151b04481 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -12,6 +12,10 @@ from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component +ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy)) + +ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy)) + @pytest.fixture def mock_is_entity_recorded(): @@ -69,6 +73,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: @pytest.mark.parametrize( ("state_class", "energy_unit", "extra"), [ + ("total_increasing", UnitOfEnergy.MILLIWATT_HOUR, {}), ("total_increasing", UnitOfEnergy.KILO_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.MEGA_WATT_HOUR, {}), ("total_increasing", UnitOfEnergy.WATT_HOUR, {}), @@ -76,6 +81,7 @@ async def test_validation_empty_config(hass: HomeAssistant) -> None: ("total", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("measurement", UnitOfEnergy.KILO_WATT_HOUR, {"last_reset": "abc"}), ("total_increasing", UnitOfEnergy.GIGA_JOULE, {}), + ("total_increasing", UnitOfEnergy.CALORIE, {}), ], ) async def test_validation( @@ -235,9 +241,7 @@ async def test_validation_device_consumption_entity_unexpected_unit( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.unexpected_unit", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -325,9 +329,7 @@ async def test_validation_solar( { "type": "entity_unexpected_unit_energy", "affected_entities": {("sensor.solar_production", "beers")}, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, } ] ], @@ -378,9 +380,7 @@ async def test_validation_battery( ("sensor.battery_import", "beers"), ("sensor.battery_export", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, ] ], @@ -449,9 +449,7 @@ async def test_validation_grid( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -538,9 +536,7 @@ async def test_validation_grid_external_cost_compensation( ("sensor.grid_consumption_1", "beers"), ("sensor.grid_production_1", "beers"), }, - "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh" - }, + "translation_placeholders": {"energy_units": ENERGY_UNITS_STRING}, }, { "type": "statistics_not_defined", @@ -710,9 +706,7 @@ async def test_validation_grid_auto_cost_entity_errors( { "type": "entity_unexpected_unit_energy_price", "affected_entities": {("sensor.grid_price_1", "$/Ws")}, - "translation_placeholders": { - "price_units": "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh" - }, + "translation_placeholders": {"price_units": ENERGY_PRICE_UNITS_STRING}, }, ), ], @@ -855,7 +849,7 @@ async def test_validation_gas( "type": "entity_unexpected_unit_gas", "affected_entities": {("sensor.gas_consumption_1", "beers")}, "translation_placeholders": { - "energy_units": "GJ, kWh, MJ, MWh, Wh", + "energy_units": ENERGY_UNITS_STRING, "gas_units": "CCF, ft³, m³, L", }, }, @@ -885,7 +879,7 @@ async def test_validation_gas( "affected_entities": {("sensor.gas_price_2", "EUR/invalid")}, "translation_placeholders": { "price_units": ( - "EUR/GJ, EUR/kWh, EUR/MJ, EUR/MWh, EUR/Wh, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" + f"{ENERGY_PRICE_UNITS_STRING}, EUR/CCF, EUR/ft³, EUR/m³, EUR/L" ) }, }, From 0e09a47476e5d5e9f898fcde182394cc744f3c52 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 10 Jul 2025 23:08:56 +0200 Subject: [PATCH 3/5] Add OpenAI AI Task entity (#148295) --- .../openai_conversation/__init__.py | 57 +-- .../components/openai_conversation/ai_task.py | 77 ++++ .../openai_conversation/config_flow.py | 88 +++-- .../components/openai_conversation/const.py | 13 + .../components/openai_conversation/entity.py | 99 +++-- .../openai_conversation/strings.json | 46 +++ .../openai_conversation/__init__.py | 240 ++++++++++++ .../openai_conversation/conftest.py | 127 ++++++- .../snapshots/test_init.ambr | 4 +- .../openai_conversation/test_ai_task.py | 124 +++++++ .../openai_conversation/test_config_flow.py | 127 ++++++- .../openai_conversation/test_conversation.py | 343 +----------------- .../openai_conversation/test_entity.py | 77 ++++ .../openai_conversation/test_init.py | 197 ++++++++-- 14 files changed, 1154 insertions(+), 465 deletions(-) create mode 100644 homeassistant/components/openai_conversation/ai_task.py create mode 100644 tests/components/openai_conversation/test_ai_task.py create mode 100644 tests/components/openai_conversation/test_entity.py diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 721ab44639ffdb..77b71ae372d831 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from types import MappingProxyType import openai from openai.types.images_response import ImagesResponse @@ -45,9 +46,11 @@ CONF_REASONING_EFFORT, CONF_TEMPERATURE, CONF_TOP_P, + DEFAULT_AI_TASK_NAME, DEFAULT_NAME, DOMAIN, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, @@ -59,7 +62,7 @@ SERVICE_GENERATE_IMAGE = "generate_image" SERVICE_GENERATE_CONTENT = "generate_content" -PLATFORMS = (Platform.CONVERSATION,) +PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient] @@ -153,28 +156,28 @@ async def send_prompt(call: ServiceCall) -> ServiceResponse: EasyInputMessageParam(type="message", role="user", content=content) ] - try: - model_args = { - "model": model, - "input": messages, - "max_output_tokens": conversation_subentry.data.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": conversation_subentry.data.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ), - "user": call.context.user_id, - "store": False, + model_args = { + "model": model, + "input": messages, + "max_output_tokens": conversation_subentry.data.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": conversation_subentry.data.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": conversation_subentry.data.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ), + "user": call.context.user_id, + "store": False, + } + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": conversation_subentry.data.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) } - if model.startswith("o"): - model_args["reasoning"] = { - "effort": conversation_subentry.data.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - + try: response: Response = await client.responses.create(**model_args) except openai.OpenAIError as err: @@ -361,6 +364,18 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/openai_conversation/ai_task.py b/homeassistant/components/openai_conversation/ai_task.py new file mode 100644 index 00000000000000..ff8c6e62520b86 --- /dev/null +++ b/homeassistant/components/openai_conversation/ai_task.py @@ -0,0 +1,77 @@ +"""AI Task integration for OpenAI.""" + +from __future__ import annotations + +from json import JSONDecodeError +import logging + +from homeassistant.components import ai_task, conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.json import json_loads + +from .entity import OpenAIBaseLLMEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up AI Task entities.""" + for subentry in config_entry.subentries.values(): + if subentry.subentry_type != "ai_task_data": + continue + + async_add_entities( + [OpenAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class OpenAITaskEntity( + ai_task.AITaskEntity, + OpenAIBaseLLMEntity, +): + """OpenAI AI Task entity.""" + + _attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_DATA + + async def _async_generate_data( + self, + task: ai_task.GenDataTask, + chat_log: conversation.ChatLog, + ) -> ai_task.GenDataTaskResult: + """Handle a generate data task.""" + await self._async_handle_chat_log(chat_log, task.name, task.structure) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + raise HomeAssistantError( + "Last content in chat log is not an AssistantContent" + ) + + text = chat_log.content[-1].content or "" + + if not task.structure: + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=text, + ) + try: + data = json_loads(text) + except JSONDecodeError as err: + _LOGGER.error( + "Failed to parse JSON response: %s. Response: %s", + err, + text, + ) + raise HomeAssistantError("Error with OpenAI structured response") from err + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=data, + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index ae1e2f31a857cb..ce6872c7c20211 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -55,9 +55,12 @@ CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_MAX_TOKENS, RECOMMENDED_REASONING_EFFORT, RECOMMENDED_TEMPERATURE, @@ -77,12 +80,6 @@ } ) -RECOMMENDED_OPTIONS = { - CONF_RECOMMENDED: True, - CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], - CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, -} - async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. @@ -99,7 +96,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -129,10 +126,16 @@ async def async_step_user( subentries=[ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ], ) @@ -146,11 +149,14 @@ def async_get_supported_subentry_types( cls, config_entry: ConfigEntry ) -> dict[str, type[ConfigSubentryFlow]]: """Return subentries supported by this integration.""" - return {"conversation": ConversationSubentryFlowHandler} + return { + "conversation": OpenAISubentryFlowHandler, + "ai_task_data": OpenAISubentryFlowHandler, + } -class ConversationSubentryFlowHandler(ConfigSubentryFlow): - """Flow for managing conversation subentries.""" +class OpenAISubentryFlowHandler(ConfigSubentryFlow): + """Flow for managing OpenAI subentries.""" last_rendered_recommended = False options: dict[str, Any] @@ -164,7 +170,10 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> SubentryFlowResult: """Add a subentry.""" - self.options = RECOMMENDED_OPTIONS.copy() + if self._subentry_type == "ai_task_data": + self.options = RECOMMENDED_AI_TASK_OPTIONS.copy() + else: + self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy() return await self.async_step_init() async def async_step_reconfigure( @@ -181,6 +190,7 @@ async def async_step_init( # abort if entry is not loaded if self._get_entry().state != ConfigEntryState.LOADED: return self.async_abort(reason="entry_not_loaded") + options = self.options hass_apis: list[SelectOptionDict] = [ @@ -198,28 +208,32 @@ async def async_step_init( step_schema: VolDictType = {} if self._is_new: - step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = ( - str + if self._subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME + else: + default_name = DEFAULT_CONVERSATION_NAME + step_schema[vol.Required(CONF_NAME, default=default_name)] = str + + if self._subentry_type == "conversation": + step_schema.update( + { + vol.Optional( + CONF_PROMPT, + description={ + "suggested_value": options.get( + CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT + ) + }, + ): TemplateSelector(), + vol.Optional(CONF_LLM_HASS_API): SelectSelector( + SelectSelectorConfig(options=hass_apis, multiple=True) + ), + } ) - step_schema.update( - { - vol.Optional( - CONF_PROMPT, - description={ - "suggested_value": options.get( - CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT - ) - }, - ): TemplateSelector(), - vol.Optional(CONF_LLM_HASS_API): SelectSelector( - SelectSelectorConfig(options=hass_apis, multiple=True) - ), - vol.Required( - CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False) - ): bool, - } - ) + step_schema[ + vol.Required(CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)) + ] = bool if user_input is not None: if not user_input.get(CONF_LLM_HASS_API): @@ -320,7 +334,9 @@ async def async_step_model( elif CONF_REASONING_EFFORT in options: options.pop(CONF_REASONING_EFFORT) - if not model.startswith(tuple(UNSUPPORTED_WEB_SEARCH_MODELS)): + if self._subentry_type == "conversation" and not model.startswith( + tuple(UNSUPPORTED_WEB_SEARCH_MODELS) + ): step_schema.update( { vol.Optional( @@ -362,7 +378,7 @@ async def async_step_model( if not step_schema: if self._is_new: return self.async_create_entry( - title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + title=options.pop(CONF_NAME), data=options, ) return self.async_update_and_abort( @@ -384,7 +400,7 @@ async def async_step_model( options.update(user_input) if self._is_new: return self.async_create_entry( - title=options.pop(CONF_NAME, DEFAULT_CONVERSATION_NAME), + title=options.pop(CONF_NAME), data=options, ) return self.async_update_and_abort( diff --git a/homeassistant/components/openai_conversation/const.py b/homeassistant/components/openai_conversation/const.py index 6a6a5b2ce6e845..777ded55657980 100644 --- a/homeassistant/components/openai_conversation/const.py +++ b/homeassistant/components/openai_conversation/const.py @@ -2,10 +2,14 @@ import logging +from homeassistant.const import CONF_LLM_HASS_API +from homeassistant.helpers import llm + DOMAIN = "openai_conversation" LOGGER: logging.Logger = logging.getLogger(__package__) DEFAULT_CONVERSATION_NAME = "OpenAI Conversation" +DEFAULT_AI_TASK_NAME = "OpenAI AI Task" DEFAULT_NAME = "OpenAI Conversation" CONF_CHAT_MODEL = "chat_model" @@ -51,3 +55,12 @@ "o1", "o3-mini", ] + +RECOMMENDED_CONVERSATION_OPTIONS = { + CONF_RECOMMENDED: True, + CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], + CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, +} +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 7351cbccbfab2b..97f3bd0ccfee9d 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -39,6 +39,7 @@ ) from openai.types.responses.response_input_param import FunctionCallOutput from openai.types.responses.web_search_tool_param import UserLocation +import voluptuous as vol from voluptuous_openapi import convert from homeassistant.components import conversation @@ -47,6 +48,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, llm from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify from .const import ( CONF_CHAT_MODEL, @@ -79,6 +81,47 @@ MAX_TOOL_ITERATIONS = 10 +def _adjust_schema(schema: dict[str, Any]) -> None: + """Adjust the schema to be compatible with OpenAI API.""" + if schema["type"] == "object": + if "properties" not in schema: + return + + if "required" not in schema: + schema["required"] = [] + + # Ensure all properties are required + for prop, prop_info in schema["properties"].items(): + _adjust_schema(prop_info) + if prop not in schema["required"]: + prop_info["type"] = [prop_info["type"], "null"] + schema["required"].append(prop) + + elif schema["type"] == "array": + if "items" not in schema: + return + + _adjust_schema(schema["items"]) + + +def _format_structured_output( + schema: vol.Schema, llm_api: llm.APIInstance | None +) -> dict[str, Any]: + """Format the schema to be compatible with OpenAI API.""" + result: dict[str, Any] = convert( + schema, + custom_serializer=( + llm_api.custom_serializer if llm_api else llm.selector_serializer + ), + ) + + _adjust_schema(result) + + result["strict"] = True + result["additionalProperties"] = False + return result + + def _format_tool( tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None ) -> FunctionToolParam: @@ -243,6 +286,8 @@ def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: async def _async_handle_chat_log( self, chat_log: conversation.ChatLog, + structure_name: str | None = None, + structure: vol.Schema | None = None, ) -> None: """Generate an answer for the chat log.""" options = self.subentry.data @@ -273,39 +318,47 @@ async def _async_handle_chat_log( tools = [] tools.append(web_search) - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + model_args = { + "model": options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + "input": [], + "max_output_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + "store": False, + "stream": True, + } + if tools: + model_args["tools"] = tools + + if model_args["model"].startswith("o"): + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + else: + model_args["store"] = False + messages = [ m for content in chat_log.content for m in _convert_content_to_param(content) ] + if structure and structure_name: + model_args["text"] = { + "format": { + "type": "json_schema", + "name": slugify(structure_name), + "schema": _format_structured_output(structure, chat_log.llm_api), + }, + } client = self.entry.runtime_data # To prevent infinite loops, we limit the number of iterations for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "input": messages, - "max_output_tokens": options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "store": False, - "stream": True, - } - if tools: - model_args["tools"] = tools - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - model_args["include"] = ["reasoning.encrypted_content"] + model_args["input"] = messages try: result = await client.responses.create(**model_args) diff --git a/homeassistant/components/openai_conversation/strings.json b/homeassistant/components/openai_conversation/strings.json index ffbe84337b7f53..5011fc9cf99236 100644 --- a/homeassistant/components/openai_conversation/strings.json +++ b/homeassistant/components/openai_conversation/strings.json @@ -68,6 +68,52 @@ "error": { "model_not_supported": "This model is not supported, please select a different model" } + }, + "ai_task_data": { + "initiate_flow": { + "user": "Add Generate data with AI service", + "reconfigure": "Reconfigure Generate data with AI service" + }, + "entry_type": "Generate data with AI service", + "step": { + "init": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::openai_conversation::config_subentries::conversation::step::init::data::recommended%]" + } + }, + "advanced": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::title%]", + "data": { + "chat_model": "[%key:common::generic::model%]", + "max_tokens": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::max_tokens%]", + "temperature": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::temperature%]", + "top_p": "[%key:component::openai_conversation::config_subentries::conversation::step::advanced::data::top_p%]" + } + }, + "model": { + "title": "[%key:component::openai_conversation::config_subentries::conversation::step::model::title%]", + "data": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data::user_location%]" + }, + "data_description": { + "reasoning_effort": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::reasoning_effort%]", + "web_search": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::web_search%]", + "search_context_size": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::search_context_size%]", + "user_location": "[%key:component::openai_conversation::config_subentries::conversation::step::model::data_description::user_location%]" + } + } + }, + "abort": { + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]" + }, + "error": { + "model_not_supported": "[%key:component::openai_conversation::config_subentries::conversation::error::model_not_supported%]" + } } }, "selector": { diff --git a/tests/components/openai_conversation/__init__.py b/tests/components/openai_conversation/__init__.py index dda2fe16a63093..11dc978250a2b3 100644 --- a/tests/components/openai_conversation/__init__.py +++ b/tests/components/openai_conversation/__init__.py @@ -1 +1,241 @@ """Tests for the OpenAI Conversation integration.""" + +from openai.types.responses import ( + ResponseContentPartAddedEvent, + ResponseContentPartDoneEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionWebSearch, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ResponseTextDoneEvent, + ResponseWebSearchCallCompletedEvent, + ResponseWebSearchCallInProgressEvent, + ResponseWebSearchCallSearchingEvent, +) +from openai.types.responses.response_function_web_search import ActionSearch + + +def create_message_item( + id: str, text: str | list[str], output_index: int +) -> list[ResponseStreamEvent]: + """Create a message item.""" + if isinstance(text, str): + text = [text] + + content = ResponseOutputText(annotations=[], text="", type="output_text") + events = [ + ResponseOutputItemAddedEvent( + item=ResponseOutputMessage( + id=id, + content=[], + type="message", + role="assistant", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseContentPartAddedEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.added", + ), + ] + + content.text = "".join(text) + events.extend( + ResponseTextDeltaEvent( + content_index=0, + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.output_text.delta", + ) + for delta in text + ) + + events.extend( + [ + ResponseTextDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + text="".join(text), + sequence_number=0, + type="response.output_text.done", + ), + ResponseContentPartDoneEvent( + content_index=0, + item_id=id, + output_index=output_index, + part=content, + sequence_number=0, + type="response.content_part.done", + ), + ResponseOutputItemDoneEvent( + item=ResponseOutputMessage( + id=id, + content=[content], + role="assistant", + status="completed", + type="message", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + ) + + return events + + +def create_function_tool_call_item( + id: str, arguments: str | list[str], call_id: str, name: str, output_index: int +) -> list[ResponseStreamEvent]: + """Create a function tool call item.""" + if isinstance(arguments, str): + arguments = [arguments] + + events = [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="", + call_id=call_id, + name=name, + type="function_call", + status="in_progress", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ) + ] + + events.extend( + ResponseFunctionCallArgumentsDeltaEvent( + delta=delta, + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.delta", + ) + for delta in arguments + ) + + events.append( + ResponseFunctionCallArgumentsDoneEvent( + arguments="".join(arguments), + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.function_call_arguments.done", + ) + ) + + events.append( + ResponseOutputItemDoneEvent( + item=ResponseFunctionToolCall( + id=id, + arguments="".join(arguments), + call_id=call_id, + name=name, + type="function_call", + status="completed", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ) + ) + + return events + + +def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a reasoning item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAA", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseOutputItemDoneEvent( + item=ResponseReasoningItem( + id=id, + summary=[], + type="reasoning", + status=None, + encrypted_content="AAABBB", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] + + +def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: + """Create a web search call item.""" + return [ + ResponseOutputItemAddedEvent( + item=ResponseFunctionWebSearch( + id=id, + status="in_progress", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.added", + ), + ResponseWebSearchCallInProgressEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.in_progress", + ), + ResponseWebSearchCallSearchingEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.searching", + ), + ResponseWebSearchCallCompletedEvent( + item_id=id, + output_index=output_index, + sequence_number=0, + type="response.web_search_call.completed", + ), + ResponseOutputItemDoneEvent( + item=ResponseFunctionWebSearch( + id=id, + status="completed", + action=ActionSearch(query="query", type="search"), + type="web_search_call", + ), + output_index=output_index, + sequence_number=0, + type="response.output_item.done", + ), + ] diff --git a/tests/components/openai_conversation/conftest.py b/tests/components/openai_conversation/conftest.py index 628c1846e16319..84c907a7c2eec2 100644 --- a/tests/components/openai_conversation/conftest.py +++ b/tests/components/openai_conversation/conftest.py @@ -1,13 +1,30 @@ """Tests helpers.""" +from collections.abc import Generator from typing import Any -from unittest.mock import patch - +from unittest.mock import AsyncMock, patch + +from openai.types import ResponseFormatText +from openai.types.responses import ( + Response, + ResponseCompletedEvent, + ResponseCreatedEvent, + ResponseError, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseIncompleteEvent, + ResponseInProgressEvent, + ResponseOutputItemDoneEvent, + ResponseTextConfig, +) +from openai.types.responses.response import IncompleteDetails import pytest from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, + RECOMMENDED_AI_TASK_OPTIONS, ) from homeassistant.config_entries import ConfigSubentryData from homeassistant.const import CONF_LLM_HASS_API @@ -19,14 +36,14 @@ @pytest.fixture -def mock_subentry_data() -> dict[str, Any]: +def mock_conversation_subentry_data() -> dict[str, Any]: """Mock subentry data.""" return {} @pytest.fixture def mock_config_entry( - hass: HomeAssistant, mock_subentry_data: dict[str, Any] + hass: HomeAssistant, mock_conversation_subentry_data: dict[str, Any] ) -> MockConfigEntry: """Mock a config entry.""" entry = MockConfigEntry( @@ -36,13 +53,20 @@ def mock_config_entry( "api_key": "bla", }, version=2, + minor_version=3, subentries_data=[ ConfigSubentryData( - data=mock_subentry_data, + data=mock_conversation_subentry_data, subentry_type="conversation", title=DEFAULT_CONVERSATION_NAME, unique_id=None, - ) + ), + ConfigSubentryData( + data=RECOMMENDED_AI_TASK_OPTIONS, + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), ], ) entry.add_to_hass(hass) @@ -91,3 +115,94 @@ async def mock_init_component( async def setup_ha(hass: HomeAssistant) -> None: """Set up Home Assistant.""" assert await async_setup_component(hass, "homeassistant", {}) + + +@pytest.fixture +def mock_create_stream() -> Generator[AsyncMock]: + """Mock stream response.""" + + async def mock_generator(events, **kwargs): + response = Response( + id="resp_A", + created_at=1700000000, + error=None, + incomplete_details=None, + instructions=kwargs.get("instructions"), + metadata=kwargs.get("metadata", {}), + model=kwargs.get("model", "gpt-4o-mini"), + object="response", + output=[], + parallel_tool_calls=kwargs.get("parallel_tool_calls", True), + temperature=kwargs.get("temperature", 1.0), + tool_choice=kwargs.get("tool_choice", "auto"), + tools=kwargs.get("tools", []), + top_p=kwargs.get("top_p", 1.0), + max_output_tokens=kwargs.get("max_output_tokens", 100000), + previous_response_id=kwargs.get("previous_response_id"), + reasoning=kwargs.get("reasoning"), + status="in_progress", + text=kwargs.get( + "text", ResponseTextConfig(format=ResponseFormatText(type="text")) + ), + truncation=kwargs.get("truncation", "disabled"), + usage=None, + user=kwargs.get("user"), + store=kwargs.get("store", True), + ) + yield ResponseCreatedEvent( + response=response, + sequence_number=0, + type="response.created", + ) + yield ResponseInProgressEvent( + response=response, + sequence_number=0, + type="response.in_progress", + ) + response.status = "completed" + + for value in events: + if isinstance(value, ResponseOutputItemDoneEvent): + response.output.append(value.item) + elif isinstance(value, IncompleteDetails): + response.status = "incomplete" + response.incomplete_details = value + break + if isinstance(value, ResponseError): + response.status = "failed" + response.error = value + break + + yield value + + if isinstance(value, ResponseErrorEvent): + return + + if response.status == "incomplete": + yield ResponseIncompleteEvent( + response=response, + sequence_number=0, + type="response.incomplete", + ) + elif response.status == "failed": + yield ResponseFailedEvent( + response=response, + sequence_number=0, + type="response.failed", + ) + else: + yield ResponseCompletedEvent( + response=response, + sequence_number=0, + type="response.completed", + ) + + with patch( + "openai.resources.responses.AsyncResponses.create", + AsyncMock(), + ) as mock_create: + mock_create.side_effect = lambda **kwargs: mock_generator( + mock_create.return_value.pop(0), **kwargs + ) + + yield mock_create diff --git a/tests/components/openai_conversation/snapshots/test_init.ambr b/tests/components/openai_conversation/snapshots/test_init.ambr index 8648e47474e66e..4eff869b0160f9 100644 --- a/tests/components/openai_conversation/snapshots/test_init.ambr +++ b/tests/components/openai_conversation/snapshots/test_init.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_devices[mock_subentry_data0] +# name: test_devices[mock_conversation_subentry_data0] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , @@ -26,7 +26,7 @@ 'via_device_id': None, }) # --- -# name: test_devices[mock_subentry_data1] +# name: test_devices[mock_conversation_subentry_data1] DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py new file mode 100644 index 00000000000000..4541e11f5f8b76 --- /dev/null +++ b/tests/components/openai_conversation/test_ai_task.py @@ -0,0 +1,124 @@ +"""Test AI Task platform of OpenAI Conversation integration.""" + +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components import ai_task +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er, selector + +from . import create_message_item + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task data generation.""" + entity_id = "ai_task.openai_ai_task" + + # Ensure entity is linked to the subentry + entity_entry = entity_registry.async_get(entity_id) + ai_task_entry = next( + iter( + entry + for entry in mock_config_entry.subentries.values() + if entry.subentry_type == "ai_task_data" + ) + ) + assert entity_entry is not None + assert entity_entry.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + # Mock the OpenAI response stream + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="The test data", output_index=0) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Generate test data", + ) + + assert result.data == "The test data" + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task structured data generation.""" + # Mock the OpenAI response stream with JSON data + mock_create_stream.return_value = [ + create_message_item( + id="msg_A", text='{"characters": ["Mario", "Luigi"]}', output_index=0 + ) + ] + + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) + + assert result.data == {"characters": ["Mario", "Luigi"]} + + +@pytest.mark.usefixtures("mock_init_component") +async def test_generate_invalid_structured_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_create_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test AI Task with invalid JSON response.""" + # Mock the OpenAI response stream with invalid JSON + mock_create_stream.return_value = [ + create_message_item(id="msg_A", text="INVALID JSON RESPONSE", output_index=0) + ] + + with pytest.raises( + HomeAssistantError, match="Error with OpenAI structured response" + ): + await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id="ai_task.openai_ai_task", + instructions="Generate test data", + structure=vol.Schema( + { + vol.Required("characters"): selector.selector( + { + "text": { + "multiple": True, + } + } + ) + }, + ), + ) diff --git a/tests/components/openai_conversation/test_config_flow.py b/tests/components/openai_conversation/test_config_flow.py index e845828570c38f..0ccbc39160a5cf 100644 --- a/tests/components/openai_conversation/test_config_flow.py +++ b/tests/components/openai_conversation/test_config_flow.py @@ -8,7 +8,9 @@ import pytest from homeassistant import config_entries -from homeassistant.components.openai_conversation.config_flow import RECOMMENDED_OPTIONS +from homeassistant.components.openai_conversation.config_flow import ( + RECOMMENDED_CONVERSATION_OPTIONS, +) from homeassistant.components.openai_conversation.const import ( CONF_CHAT_MODEL, CONF_MAX_TOKENS, @@ -24,8 +26,10 @@ CONF_WEB_SEARCH_REGION, CONF_WEB_SEARCH_TIMEZONE, CONF_WEB_SEARCH_USER_LOCATION, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_MAX_TOKENS, RECOMMENDED_TOP_P, @@ -77,10 +81,16 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["subentries"] == [ { "subentry_type": "conversation", - "data": RECOMMENDED_OPTIONS, + "data": RECOMMENDED_CONVERSATION_OPTIONS, "title": DEFAULT_CONVERSATION_NAME, "unique_id": None, - } + }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ] assert len(mock_setup_entry.mock_calls) == 1 @@ -131,14 +141,14 @@ async def test_creating_conversation_subentry( result2 = await hass.config_entries.subentries.async_configure( result["flow_id"], - {"name": "My Custom Agent", **RECOMMENDED_OPTIONS}, + {"name": "My Custom Agent", **RECOMMENDED_CONVERSATION_OPTIONS}, ) await hass.async_block_till_done() assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == "My Custom Agent" - processed_options = RECOMMENDED_OPTIONS.copy() + processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy() processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip() assert result2["data"] == processed_options @@ -709,3 +719,110 @@ async def test_subentry_web_search_user_location( CONF_WEB_SEARCH_COUNTRY: "US", CONF_WEB_SEARCH_TIMEZONE: "America/Los_Angeles", } + + +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry.""" + old_subentries = set(mock_config_entry.subentries) + # Original conversation + original ai_task + assert len(mock_config_entry.subentries) == 2 + + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + assert not result.get("errors") + + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Custom AI Task", + CONF_RECOMMENDED: True, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") is FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Custom AI Task" + assert result2.get("data") == { + CONF_RECOMMENDED: True, + } + + assert ( + len(mock_config_entry.subentries) == 3 + ) # Original conversation + original ai_task + new ai_task + + new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] + new_subentry = mock_config_entry.subentries[new_subentry_id] + assert new_subentry.subentry_type == "ai_task_data" + assert new_subentry.title == "Custom AI Task" + + +async def test_ai_task_subentry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry when entry is not loaded.""" + # Don't call mock_init_component to simulate not loaded state + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "entry_not_loaded" + + +async def test_creating_ai_task_subentry_advanced( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test creating an AI task subentry with advanced settings.""" + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "init" + + # Go to advanced settings + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + "name": "Advanced AI Task", + CONF_RECOMMENDED: False, + }, + ) + + assert result2.get("type") is FlowResultType.FORM + assert result2.get("step_id") == "advanced" + + # Configure advanced settings + result3 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + { + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + }, + ) + + assert result3.get("type") is FlowResultType.CREATE_ENTRY + assert result3.get("title") == "Advanced AI Task" + assert result3.get("data") == { + CONF_RECOMMENDED: False, + CONF_CHAT_MODEL: "gpt-4o", + CONF_MAX_TOKENS: 200, + CONF_TEMPERATURE: 0.5, + CONF_TOP_P: 0.9, + } diff --git a/tests/components/openai_conversation/test_conversation.py b/tests/components/openai_conversation/test_conversation.py index 7a3bcb2176868b..39cd129e1ba763 100644 --- a/tests/components/openai_conversation/test_conversation.py +++ b/tests/components/openai_conversation/test_conversation.py @@ -1,41 +1,15 @@ """Tests for the OpenAI integration.""" -from collections.abc import Generator from unittest.mock import AsyncMock, patch import httpx from openai import AuthenticationError, RateLimitError -from openai.types import ResponseFormatText from openai.types.responses import ( - Response, - ResponseCompletedEvent, - ResponseContentPartAddedEvent, - ResponseContentPartDoneEvent, - ResponseCreatedEvent, ResponseError, ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionWebSearch, - ResponseIncompleteEvent, - ResponseInProgressEvent, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputText, - ResponseReasoningItem, ResponseStreamEvent, - ResponseTextConfig, - ResponseTextDeltaEvent, - ResponseTextDoneEvent, - ResponseWebSearchCallCompletedEvent, - ResponseWebSearchCallInProgressEvent, - ResponseWebSearchCallSearchingEvent, ) from openai.types.responses.response import IncompleteDetails -from openai.types.responses.response_function_web_search import ActionSearch import pytest from syrupy.assertion import SnapshotAssertion @@ -55,6 +29,13 @@ from homeassistant.helpers import intent from homeassistant.setup import async_setup_component +from . import ( + create_function_tool_call_item, + create_message_item, + create_reasoning_item, + create_web_search_item, +) + from tests.common import MockConfigEntry from tests.components.conversation import ( MockChatLog, @@ -62,97 +43,6 @@ ) -@pytest.fixture -def mock_create_stream() -> Generator[AsyncMock]: - """Mock stream response.""" - - async def mock_generator(events, **kwargs): - response = Response( - id="resp_A", - created_at=1700000000, - error=None, - incomplete_details=None, - instructions=kwargs.get("instructions"), - metadata=kwargs.get("metadata", {}), - model=kwargs.get("model", "gpt-4o-mini"), - object="response", - output=[], - parallel_tool_calls=kwargs.get("parallel_tool_calls", True), - temperature=kwargs.get("temperature", 1.0), - tool_choice=kwargs.get("tool_choice", "auto"), - tools=kwargs.get("tools"), - top_p=kwargs.get("top_p", 1.0), - max_output_tokens=kwargs.get("max_output_tokens", 100000), - previous_response_id=kwargs.get("previous_response_id"), - reasoning=kwargs.get("reasoning"), - status="in_progress", - text=kwargs.get( - "text", ResponseTextConfig(format=ResponseFormatText(type="text")) - ), - truncation=kwargs.get("truncation", "disabled"), - usage=None, - user=kwargs.get("user"), - store=kwargs.get("store", True), - ) - yield ResponseCreatedEvent( - response=response, - sequence_number=0, - type="response.created", - ) - yield ResponseInProgressEvent( - response=response, - sequence_number=0, - type="response.in_progress", - ) - response.status = "completed" - - for value in events: - if isinstance(value, ResponseOutputItemDoneEvent): - response.output.append(value.item) - elif isinstance(value, IncompleteDetails): - response.status = "incomplete" - response.incomplete_details = value - break - if isinstance(value, ResponseError): - response.status = "failed" - response.error = value - break - - yield value - - if isinstance(value, ResponseErrorEvent): - return - - if response.status == "incomplete": - yield ResponseIncompleteEvent( - response=response, - sequence_number=0, - type="response.incomplete", - ) - elif response.status == "failed": - yield ResponseFailedEvent( - response=response, - sequence_number=0, - type="response.failed", - ) - else: - yield ResponseCompletedEvent( - response=response, - sequence_number=0, - type="response.completed", - ) - - with patch( - "openai.resources.responses.AsyncResponses.create", - AsyncMock(), - ) as mock_create: - mock_create.side_effect = lambda **kwargs: mock_generator( - mock_create.return_value.pop(0), **kwargs - ) - - yield mock_create - - async def test_entity( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -347,225 +237,6 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -def create_message_item( - id: str, text: str | list[str], output_index: int -) -> list[ResponseStreamEvent]: - """Create a message item.""" - if isinstance(text, str): - text = [text] - - content = ResponseOutputText(annotations=[], text="", type="output_text") - events = [ - ResponseOutputItemAddedEvent( - item=ResponseOutputMessage( - id=id, - content=[], - type="message", - role="assistant", - status="in_progress", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseContentPartAddedEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - sequence_number=0, - type="response.content_part.added", - ), - ] - - content.text = "".join(text) - events.extend( - ResponseTextDeltaEvent( - content_index=0, - delta=delta, - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.output_text.delta", - ) - for delta in text - ) - - events.extend( - [ - ResponseTextDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - text="".join(text), - sequence_number=0, - type="response.output_text.done", - ), - ResponseContentPartDoneEvent( - content_index=0, - item_id=id, - output_index=output_index, - part=content, - sequence_number=0, - type="response.content_part.done", - ), - ResponseOutputItemDoneEvent( - item=ResponseOutputMessage( - id=id, - content=[content], - role="assistant", - status="completed", - type="message", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - ) - - return events - - -def create_function_tool_call_item( - id: str, arguments: str | list[str], call_id: str, name: str, output_index: int -) -> list[ResponseStreamEvent]: - """Create a function tool call item.""" - if isinstance(arguments, str): - arguments = [arguments] - - events = [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="", - call_id=call_id, - name=name, - type="function_call", - status="in_progress", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ) - ] - - events.extend( - ResponseFunctionCallArgumentsDeltaEvent( - delta=delta, - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.function_call_arguments.delta", - ) - for delta in arguments - ) - - events.append( - ResponseFunctionCallArgumentsDoneEvent( - arguments="".join(arguments), - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.function_call_arguments.done", - ) - ) - - events.append( - ResponseOutputItemDoneEvent( - item=ResponseFunctionToolCall( - id=id, - arguments="".join(arguments), - call_id=call_id, - name=name, - type="function_call", - status="completed", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ) - ) - - return events - - -def create_reasoning_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a reasoning item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - encrypted_content="AAA", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseOutputItemDoneEvent( - item=ResponseReasoningItem( - id=id, - summary=[], - type="reasoning", - status=None, - encrypted_content="AAABBB", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - - -def create_web_search_item(id: str, output_index: int) -> list[ResponseStreamEvent]: - """Create a web search call item.""" - return [ - ResponseOutputItemAddedEvent( - item=ResponseFunctionWebSearch( - id=id, - status="in_progress", - action=ActionSearch(query="query", type="search"), - type="web_search_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.added", - ), - ResponseWebSearchCallInProgressEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.in_progress", - ), - ResponseWebSearchCallSearchingEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.searching", - ), - ResponseWebSearchCallCompletedEvent( - item_id=id, - output_index=output_index, - sequence_number=0, - type="response.web_search_call.completed", - ), - ResponseOutputItemDoneEvent( - item=ResponseFunctionWebSearch( - id=id, - status="completed", - action=ActionSearch(query="query", type="search"), - type="web_search_call", - ), - output_index=output_index, - sequence_number=0, - type="response.output_item.done", - ), - ] - - async def test_function_call( hass: HomeAssistant, mock_config_entry_with_reasoning_model: MockConfigEntry, diff --git a/tests/components/openai_conversation/test_entity.py b/tests/components/openai_conversation/test_entity.py new file mode 100644 index 00000000000000..58187bd63e98da --- /dev/null +++ b/tests/components/openai_conversation/test_entity.py @@ -0,0 +1,77 @@ +"""Tests for the OpenAI Conversation entity.""" + +import voluptuous as vol + +from homeassistant.components.openai_conversation.entity import ( + _format_structured_output, +) +from homeassistant.helpers import selector + + +async def test_format_structured_output() -> None: + """Test the format_structured_output function.""" + schema = vol.Schema( + { + vol.Required("name"): selector.TextSelector(), + vol.Optional("age"): selector.NumberSelector( + config=selector.NumberSelectorConfig( + min=0, + max=120, + ), + ), + vol.Required("stuff"): selector.ObjectSelector( + { + "multiple": True, + "fields": { + "item_name": { + "selector": {"text": None}, + }, + "item_value": { + "selector": {"text": None}, + }, + }, + } + ), + } + ) + assert _format_structured_output(schema, None) == { + "additionalProperties": False, + "properties": { + "age": { + "maximum": 120.0, + "minimum": 0.0, + "type": [ + "number", + "null", + ], + }, + "name": { + "type": "string", + }, + "stuff": { + "items": { + "properties": { + "item_name": { + "type": ["string", "null"], + }, + "item_value": { + "type": ["string", "null"], + }, + }, + "required": [ + "item_name", + "item_value", + ], + "type": "object", + }, + "type": "array", + }, + }, + "required": [ + "name", + "stuff", + "age", + ], + "strict": True, + "type": "object", + } diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index 3e13cb3dd1c7ba..7af1151075ca37 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -17,7 +17,10 @@ from syrupy.filters import props from homeassistant.components.openai_conversation import CONF_CHAT_MODEL -from homeassistant.components.openai_conversation.const import DOMAIN +from homeassistant.components.openai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigSubentryData from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError @@ -534,7 +537,7 @@ async def test_generate_content_service_error( ) -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -582,17 +585,33 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} - assert len(mock_config_entry.subentries) == 1 - - subentry = next(iter(mock_config_entry.subentries.values())) - assert subentry.unique_id is None - assert subentry.title == "ChatGPT" - assert subentry.subentry_type == "conversation" - assert subentry.data == OPTIONS + assert len(mock_config_entry.subentries) == 2 + + # Find the conversation subentry + conversation_subentry = None + ai_task_subentry = None + for subentry in mock_config_entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + elif subentry.subentry_type == "ai_task_data": + ai_task_subentry = subentry + assert conversation_subentry is not None + assert conversation_subentry.unique_id is None + assert conversation_subentry.title == "ChatGPT" + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == OPTIONS + + assert ai_task_subentry is not None + assert ai_task_subentry.unique_id is None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.subentry_type == "ai_task_data" + + # Use conversation subentry for the rest of the assertions + subentry = conversation_subentry migrated_entity = entity_registry.async_get(entity.entity_id) assert migrated_entity is not None @@ -617,12 +636,12 @@ async def test_migration_from_v1_to_v2( } -async def test_migration_from_v1_to_v2_with_multiple_keys( +async def test_migration_from_v1_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with different API keys.""" + """Test migration from version 1 with different API keys.""" # Create two v1 config entries with different API keys options = { "recommended": True, @@ -695,28 +714,38 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options - assert len(entry.subentries) == 1 - subentry = list(entry.subentries.values())[0] - assert subentry.subentry_type == "conversation" - assert subentry.data == options - assert subentry.title == f"ChatGPT {idx + 1}" + assert len(entry.subentries) == 2 + + conversation_subentry = None + for subentry in entry.subentries.values(): + if subentry.subentry_type == "conversation": + conversation_subentry = subentry + break + + assert conversation_subentry is not None + assert conversation_subentry.subentry_type == "conversation" + assert conversation_subentry.data == options + assert conversation_subentry.title == f"ChatGPT {idx + 1}" + + # Use conversation subentry for device assertions + subentry = conversation_subentry dev = device_registry.async_get_device( - identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} + identifiers={(DOMAIN, subentry.subentry_id)} ) assert dev is not None assert dev.config_entries == {entry.entry_id} assert dev.config_entries_subentries == {entry.entry_id: {subentry.subentry_id}} -async def test_migration_from_v1_to_v2_with_same_keys( +async def test_migration_from_v1_with_same_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 1 to version 2 with same API keys consolidates entries.""" + """Test migration from version 1 with same API keys consolidates entries.""" # Create two v1 config entries with the same API key options = { "recommended": True, @@ -790,17 +819,28 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options - assert len(entry.subentries) == 2 # Two subentries from the two original entries + assert ( + len(entry.subentries) == 3 + ) # Two conversation subentries + one AI task subentry - # Check both subentries exist with correct data - subentries = list(entry.subentries.values()) - titles = [sub.title for sub in subentries] + # Check both conversation subentries exist with correct data + conversation_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "conversation" + ] + ai_task_subentries = [ + sub for sub in entry.subentries.values() if sub.subentry_type == "ai_task_data" + ] + + assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 + + titles = [sub.title for sub in conversation_subentries] assert "ChatGPT" in titles assert "ChatGPT 2" in titles - for subentry in subentries: + for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -815,12 +855,12 @@ async def test_migration_from_v1_to_v2_with_same_keys( } -async def test_migration_from_v2_1_to_v2_2( +async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: - """Test migration from version 2.1 to version 2.2. + """Test migration from version 2.1. This tests we clean up the broken migration in Home Assistant Core 2025.7.0b0-2025.7.0b1: @@ -913,16 +953,22 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "ChatGPT" - assert len(entry.subentries) == 2 + assert len(entry.subentries) == 3 # 2 conversation + 1 AI task conversation_subentries = [ subentry for subentry in entry.subentries.values() if subentry.subentry_type == "conversation" ] + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] assert len(conversation_subentries) == 2 + assert len(ai_task_subentries) == 1 for subentry in conversation_subentries: assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -972,7 +1018,9 @@ async def test_migration_from_v2_1_to_v2_2( } -@pytest.mark.parametrize("mock_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}]) +@pytest.mark.parametrize( + "mock_conversation_subentry_data", [{}, {CONF_CHAT_MODEL: "gpt-1o"}] +) async def test_devices( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -980,12 +1028,89 @@ async def test_devices( device_registry: dr.DeviceRegistry, snapshot: SnapshotAssertion, ) -> None: - """Assert exception when invalid config entry is provided.""" + """Test devices are correctly created for subentries.""" devices = dr.async_entries_for_config_entry( device_registry, mock_config_entry.entry_id ) - assert len(devices) == 1 + assert len(devices) == 2 # One for conversation, one for AI task + + # Use the first device for snapshot comparison device = devices[0] assert device == snapshot(exclude=props("identifiers")) - subentry = next(iter(mock_config_entry.subentries.values())) - assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + # Verify the device has identifiers matching one of the subentries + expected_identifiers = [ + {(DOMAIN, subentry.subentry_id)} + for subentry in mock_config_entry.subentries.values() + ] + assert device.identifiers in expected_identifiers + + +async def test_migration_from_v2_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with a conversation subentry + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"api_key": "1234"}, + entry_id="mock_entry_id", + version=2, + minor_version=2, + subentries_data=[ + ConfigSubentryData( + data=options, + subentry_id="mock_id_1", + subentry_type="conversation", + title="ChatGPT", + unique_id=None, + ), + ], + title="ChatGPT", + ) + mock_config_entry.add_to_hass(hass) + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "ChatGPT" + assert len(entry.subentries) == 2 + + # Check conversation subentry is still there + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 1 + conversation_subentry = conversation_subentries[0] + assert conversation_subentry.data == options + + # Check AI Task subentry was added + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + ai_task_subentry = ai_task_subentries[0] + assert ai_task_subentry.data == {"recommended": True} + assert ai_task_subentry.title == "OpenAI AI Task" From 6eeec948a8d64d458f4bc988343d9de183a38114 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 10 Jul 2025 23:09:47 +0200 Subject: [PATCH 4/5] Update frontend to 20250702.2 (#148573) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 748d8f0c6f0052..a7582ebc5e25aa 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250702.1"] + "requirements": ["home-assistant-frontend==20250702.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b73a458b7ec66b..52cfa22212d666 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==3.49.0 hass-nabucasa==0.106.0 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 home-assistant-intents==2025.6.23 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b4a53c7dba7e81..dd5108a807a246 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1168,7 +1168,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3173b1443b6e17..4cd94a3f6ff835 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,7 +1017,7 @@ hole==0.9.0 holidays==0.76 # homeassistant.components.frontend -home-assistant-frontend==20250702.1 +home-assistant-frontend==20250702.2 # homeassistant.components.conversation home-assistant-intents==2025.6.23 From 18a89d58156de33233120f995a166490cd53c223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 10 Jul 2025 11:10:48 -1000 Subject: [PATCH 5/5] Bump aiohttp to 3.12.14 (#148565) --- homeassistant/components/http/ban.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index 7e55191639b997..71f3d54bef6abf 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -64,7 +64,7 @@ async def ban_startup(app: Application) -> None: """Initialize bans when app starts up.""" await app[KEY_BAN_MANAGER].async_load() - app.on_startup.append(ban_startup) # type: ignore[arg-type] + app.on_startup.append(ban_startup) @middleware diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 52cfa22212d666..89ff2238f61d64 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ aiodns==3.5.0 aiohasupervisor==0.3.1 aiohttp-asyncmdnsresolver==0.1.1 aiohttp-fast-zlib==0.3.0 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiousbwatcher==1.1.1 aiozoneinfo==0.2.3 diff --git a/pyproject.toml b/pyproject.toml index 3841d234ddfe68..3ea2a9c9f1b3f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ # change behavior based on presence of supervisor. Deprecated with #127228 # Lib can be removed with 2025.11 "aiohasupervisor==0.3.1", - "aiohttp==3.12.13", + "aiohttp==3.12.14", "aiohttp_cors==0.8.1", "aiohttp-fast-zlib==0.3.0", "aiohttp-asyncmdnsresolver==0.1.1", diff --git a/requirements.txt b/requirements.txt index c246af65758f4d..118d2bedfa6a6e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,7 @@ # Home Assistant Core aiodns==3.5.0 aiohasupervisor==0.3.1 -aiohttp==3.12.13 +aiohttp==3.12.14 aiohttp_cors==0.8.1 aiohttp-fast-zlib==0.3.0 aiohttp-asyncmdnsresolver==0.1.1