From e5f7421703b88e74a68c1589093d59cf68181a4b Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Jul 2025 21:04:13 +0200 Subject: [PATCH 1/9] Bump pyenphase to 2.2.0 (#148070) --- homeassistant/components/enphase_envoy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 5f74da954a0..8387ecc9c9f 100644 --- a/homeassistant/components/enphase_envoy/manifest.json +++ b/homeassistant/components/enphase_envoy/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_polling", "loggers": ["pyenphase"], "quality_scale": "platinum", - "requirements": ["pyenphase==2.1.0"], + "requirements": ["pyenphase==2.2.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index f80bb901946..4b622fe9c35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1962,7 +1962,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.2.0 # homeassistant.components.envisalink pyenvisalink==4.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0211c9803c9..4fbebe1bf9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1637,7 +1637,7 @@ pyeiscp==0.0.7 pyemoncms==0.1.1 # homeassistant.components.enphase_envoy -pyenphase==2.1.0 +pyenphase==2.2.0 # homeassistant.components.everlights pyeverlights==0.1.0 From b410b414ec146f9b9e0e535781f481fc0e1f5e23 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 3 Jul 2025 13:00:07 -0700 Subject: [PATCH 2/9] Add reconfigure flow in Android TV Remote (#148044) --- .../androidtv_remote/config_flow.py | 36 +++++-- .../components/androidtv_remote/strings.json | 13 ++- .../androidtv_remote/test_config_flow.py | 97 +++++++++++++++++++ 3 files changed, 136 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 25a26fc92df..351cae61b1d 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.config_entries import ( SOURCE_REAUTH, + SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, OptionsFlow, @@ -40,12 +41,6 @@ APPS_NEW_ID = "NewApp" CONF_APP_DELETE = "app_delete" CONF_APP_ID = "app_id" -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required("host"): str, - } -) - STEP_PAIR_DATA_SCHEMA = vol.Schema( { vol.Required("pin"): str, @@ -66,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial and reconfigure step.""" errors: dict[str, str] = {} if user_input is not None: self.host = user_input[CONF_HOST] @@ -75,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): await api.async_generate_cert_if_missing() self.name, self.mac = await api.async_get_name_and_mac() await self.async_set_unique_id(format_mac(self.mac)) + if self.source == SOURCE_RECONFIGURE: + self._abort_if_unique_id_mismatch() + return self.async_update_reload_and_abort( + self._get_reconfigure_entry(), + data={ + CONF_HOST: self.host, + CONF_NAME: self.name, + CONF_MAC: self.mac, + }, + ) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) return await self._async_start_pair() except (CannotConnect, ConnectionClosed): # Likely invalid IP address or device is network unreachable. Stay # in the user step allowing the user to enter a different host. errors["base"] = "cannot_connect" + else: + user_input = {} + default_host = user_input.get(CONF_HOST, vol.UNDEFINED) + if self.source == SOURCE_RECONFIGURE: + default_host = self._get_reconfigure_entry().data[CONF_HOST] return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, + step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user", + data_schema=vol.Schema( + {vol.Required(CONF_HOST, default=default_host): str} + ), errors=errors, ) @@ -216,6 +228,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration.""" + return await self.async_step_user(user_input) + @staticmethod @callback def async_get_options_flow( diff --git a/homeassistant/components/androidtv_remote/strings.json b/homeassistant/components/androidtv_remote/strings.json index 7130c5b2b3b..d0eb1d0dca4 100644 --- a/homeassistant/components/androidtv_remote/strings.json +++ b/homeassistant/components/androidtv_remote/strings.json @@ -11,6 +11,15 @@ "host": "The hostname or IP address of the Android TV device." } }, + "reconfigure": { + "description": "Update the IP address of this previously configured Android TV device.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "The hostname or IP address of the Android TV device." + } + }, "zeroconf_confirm": { "title": "Discovered Android TV", "description": "Do you want to add the Android TV ({name}) to Home Assistant? It will turn on and a pairing code will be displayed on it that you will need to enter in the next screen." @@ -38,7 +47,9 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "unique_id_mismatch": "Please ensure you reconfigure against the same device." } }, "options": { diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 0968ea5acff..9652ac0c3a9 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1069,3 +1069,100 @@ async def test_options_flow( ) assert result["type"] is FlowResultType.CREATE_ENTRY assert mock_config_entry.options == {CONF_ENABLE_IME: True} + + +async def test_reconfigure_flow_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test the full reconfigure flow from start to finish without any exceptions.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert not result["errors"] + assert "host" in result["data_schema"].schema + # Form should have as default value the existing host + host_key = next(k for k in result["data_schema"].schema if k.schema == "host") + assert host_key.default() == mock_config_entry.data["host"] + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock( + return_value=(mock_config_entry.data["name"], mock_config_entry.data["mac"]) + ) + + # Simulate user input with a new host + new_host = "4.3.2.1" + assert new_host != mock_config_entry.data["host"] + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data["host"] == new_host + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_reconfigure_flow_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with CannotConnect exception.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + mock_api.async_get_name_and_mac = AsyncMock(side_effect=CannotConnect()) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + assert result["errors"] == {"base": "cannot_connect"} + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_reconfigure_flow_unique_id_mismatch( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_setup_entry: AsyncMock, + mock_api: MagicMock, +) -> None: + """Test reconfigure flow with a different device (unique_id mismatch).""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_api.async_generate_cert_if_missing = AsyncMock(return_value=True) + # The new host corresponds to a device with a different MAC/unique_id + new_mac = "FF:EE:DD:CC:BB:AA" + assert new_mac != mock_config_entry.data["mac"] + mock_api.async_get_name_and_mac = AsyncMock(return_value=("name", new_mac)) + + new_host = "4.3.2.1" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"host": new_host} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unique_id_mismatch" + assert mock_config_entry.data["host"] == "1.2.3.4" + assert len(mock_setup_entry.mock_calls) == 0 From 8ef6b62d9a4a19f10c9240279f1b9c3d7ce43cff Mon Sep 17 00:00:00 2001 From: Arie Catsman <120491684+catsmanac@users.noreply.github.com> Date: Thu, 3 Jul 2025 22:06:38 +0200 Subject: [PATCH 3/9] Cancel enphase mac verification on unload. (#148072) --- homeassistant/components/enphase_envoy/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index eee6cb85e6d..f43d89aa098 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -63,6 +63,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) -> coordinator = entry.runtime_data coordinator.async_cancel_token_refresh() coordinator.async_cancel_firmware_refresh() + coordinator.async_cancel_mac_verification() return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) From 11c75d7ef2e7985e222502a6532a6be85d854958 Mon Sep 17 00:00:00 2001 From: HeroOfCanton16 <49348182+HeroOfCanton16@users.noreply.github.com> Date: Thu, 3 Jul 2025 17:10:26 -0400 Subject: [PATCH 4/9] Add sensor attributes restore to modem_callerid integration (#147753) --- .../components/modem_callerid/sensor.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index de8e4b2f73c..db901511d5f 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from phone_modem import PhoneModem -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import RestoreSensor from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.core import Event, HomeAssistant, callback @@ -40,7 +40,7 @@ async def async_setup_entry( ) -class ModemCalleridSensor(SensorEntity): +class ModemCalleridSensor(RestoreSensor): """Implementation of USB modem caller ID sensor.""" _attr_should_poll = False @@ -62,9 +62,21 @@ class ModemCalleridSensor(SensorEntity): async def async_added_to_hass(self) -> None: """Call when the modem sensor is added to Home Assistant.""" - self.api.registercallback(self._async_incoming_call) await super().async_added_to_hass() + if (last_state := await self.async_get_last_state()) is not None: + self._attr_extra_state_attributes[CID.CID_NAME] = last_state.attributes.get( + CID.CID_NAME, "" + ) + self._attr_extra_state_attributes[CID.CID_NUMBER] = ( + last_state.attributes.get(CID.CID_NUMBER, "") + ) + self._attr_extra_state_attributes[CID.CID_TIME] = last_state.attributes.get( + CID.CID_TIME, 0 + ) + + self.api.registercallback(self._async_incoming_call) + @callback def _async_incoming_call(self, new_state: str) -> None: """Handle new states.""" From 49d1d781b8991e82cd8c531981129629c9999594 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 3 Jul 2025 23:11:54 +0200 Subject: [PATCH 5/9] Fix ezviz test timeout (#148066) --- tests/components/ezviz/test_config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 20d70902e83..ff34134b3fb 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -129,6 +129,7 @@ async def test_async_step_reauth( CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" @@ -639,6 +640,7 @@ async def test_reauth_errors( CONF_PASSWORD: "test-password", }, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" From a3b03caead353924fb46e4f4e3524e7682709c71 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 4 Jul 2025 07:55:20 +0200 Subject: [PATCH 6/9] Deduce integration from module in `loader.async_get_issue_tracker` (#148017) --- homeassistant/loader.py | 7 +++++++ tests/test_loader.py | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ae3709e383b..a66a09d7407 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1789,6 +1789,13 @@ def async_get_issue_tracker( # If we know nothing about the integration, suggest opening an issue on HA core return issue_tracker + if module and not integration_domain: + # If we only have a module, we can try to get the integration domain from it + if module.startswith("custom_components."): + integration_domain = module.split(".")[1] + elif module.startswith("homeassistant.components."): + integration_domain = module.split(".")[2] + if not integration: integration = async_get_issue_integration(hass, integration_domain) diff --git a/tests/test_loader.py b/tests/test_loader.py index 2d5ad76aa8a..c67b520c7dc 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1143,10 +1143,10 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE), ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), - # Integration domain is not currently deduced from module - (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), # Loaded custom integration with known issue tracker + (None, "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER), # Loaded custom integration without known issue tracker @@ -1155,6 +1155,7 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com" ("bla_custom_no_tracker", None, None), ("hue", "custom_components.bla.sensor", None), # Unloaded custom integration with known issue tracker + (None, "custom_components.bla_custom_not_loaded.sensor", CUSTOM_ISSUE_TRACKER), ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER), # Unloaded custom integration without known issue tracker ("bla_custom_not_loaded_no_tracker", None, None), @@ -1218,8 +1219,7 @@ async def test_async_get_issue_tracker( ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE), ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), - # Integration domain is not currently deduced from module - (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER), + (None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), # Custom integration with known issue tracker - can't find it without hass ("bla_custom", "custom_components.bla_custom.sensor", None), From 04cc451c765703d8714993b296a06fb182a78dd8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 4 Jul 2025 08:36:34 +0200 Subject: [PATCH 7/9] Add AI Task platform to Google Gen AI (#146766) --- .../__init__.py | 25 +++- .../ai_task.py | 57 ++++++++ .../config_flow.py | 26 +++- .../const.py | 6 + .../strings.json | 28 ++++ .../conftest.py | 9 ++ .../snapshots/test_diagnostics.ambr | 8 + .../snapshots/test_init.ambr | 31 ++++ .../test_ai_task.py | 62 ++++++++ .../test_config_flow.py | 58 +++++++- .../test_init.py | 138 ++++++++++++++++-- 11 files changed, 423 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/google_generative_ai_conversation/ai_task.py create mode 100644 tests/components/google_generative_ai_conversation/test_ai_task.py diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 346d5322b02..99e475a376b 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from functools import partial import mimetypes from pathlib import Path from types import MappingProxyType @@ -37,11 +38,13 @@ from homeassistant.helpers.typing import ConfigType from .const import ( CONF_PROMPT, + DEFAULT_AI_TASK_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, FILE_POLLING_INTERVAL_SECONDS, LOGGER, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_TTS_OPTIONS, TIMEOUT_MILLIS, @@ -53,6 +56,7 @@ CONF_FILENAMES = "filenames" CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) PLATFORMS = ( + Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS, ) @@ -187,11 +191,9 @@ async def async_setup_entry( """Set up Google Generative AI Conversation from a config entry.""" try: - - def _init_client() -> Client: - return Client(api_key=entry.data[CONF_API_KEY]) - - client = await hass.async_add_executor_job(_init_client) + client = await hass.async_add_executor_job( + partial(Client, api_key=entry.data[CONF_API_KEY]) + ) await client.aio.models.get( model=RECOMMENDED_CHAT_MODEL, config={"http_options": {"timeout": TIMEOUT_MILLIS}}, @@ -350,6 +352,19 @@ async def async_migrate_entry( hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Add AI Task subentry with default options + 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/google_generative_ai_conversation/ai_task.py b/homeassistant/components/google_generative_ai_conversation/ai_task.py new file mode 100644 index 00000000000..ab34af71ebe --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/ai_task.py @@ -0,0 +1,57 @@ +"""AI Task integration for Google Generative AI Conversation.""" + +from __future__ import annotations + +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 .const import LOGGER +from .entity import ERROR_GETTING_RESPONSE, GoogleGenerativeAILLMBaseEntity + + +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( + [GoogleGenerativeAITaskEntity(config_entry, subentry)], + config_subentry_id=subentry.subentry_id, + ) + + +class GoogleGenerativeAITaskEntity( + ai_task.AITaskEntity, + GoogleGenerativeAILLMBaseEntity, +): + """Google Generative AI 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) + + if not isinstance(chat_log.content[-1], conversation.AssistantContent): + LOGGER.error( + "Last content in chat log is not an AssistantContent: %s. This could be due to the model not returning a valid response", + chat_log.content[-1], + ) + raise HomeAssistantError(ERROR_GETTING_RESPONSE) + + return ai_task.GenDataTaskResult( + conversation_id=chat_log.conversation_id, + data=chat_log.content[-1].content or "", + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index ade326cf71b..a68ca09e76d 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from functools import partial import logging from typing import Any, cast @@ -46,10 +47,12 @@ from .const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -72,12 +75,14 @@ STEP_API_DATA_SCHEMA = vol.Schema( ) -async def validate_input(data: dict[str, Any]) -> None: +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - client = genai.Client(api_key=data[CONF_API_KEY]) + client = await hass.async_add_executor_job( + partial(genai.Client, api_key=data[CONF_API_KEY]) + ) await client.aio.models.list( config={ "http_options": { @@ -92,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Google Generative AI Conversation.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_api( self, user_input: dict[str, Any] | None = None @@ -102,7 +107,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: self._async_abort_entries_match(user_input) try: - await validate_input(user_input) + await validate_input(self.hass, user_input) except (APIError, Timeout) as err: if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err): errors["base"] = "invalid_auth" @@ -133,6 +138,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): "title": DEFAULT_TTS_NAME, "unique_id": None, }, + { + "subentry_type": "ai_task_data", + "data": RECOMMENDED_AI_TASK_OPTIONS, + "title": DEFAULT_AI_TASK_NAME, + "unique_id": None, + }, ], ) return self.async_show_form( @@ -181,6 +192,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): return { "conversation": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler, + "ai_task_data": LLMSubentryFlowHandler, } @@ -214,6 +226,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow): options: dict[str, Any] if self._subentry_type == "tts": options = RECOMMENDED_TTS_OPTIONS.copy() + elif self._subentry_type == "ai_task_data": + options = RECOMMENDED_AI_TASK_OPTIONS.copy() else: options = RECOMMENDED_CONVERSATION_OPTIONS.copy() else: @@ -288,6 +302,8 @@ async def google_generative_ai_config_option_schema( default_name = options[CONF_NAME] elif subentry_type == "tts": default_name = DEFAULT_TTS_NAME + elif subentry_type == "ai_task_data": + default_name = DEFAULT_AI_TASK_NAME else: default_name = DEFAULT_CONVERSATION_NAME schema: dict[vol.Required | vol.Optional, Any] = { @@ -315,6 +331,7 @@ async def google_generative_ai_config_option_schema( ), } ) + schema.update( { vol.Required( @@ -443,4 +460,5 @@ async def google_generative_ai_config_option_schema( ): bool, } ) + return schema diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py index 72665cd3437..e7c5ba6bd22 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -12,6 +12,7 @@ CONF_PROMPT = "prompt" DEFAULT_CONVERSATION_NAME = "Google AI Conversation" DEFAULT_TTS_NAME = "Google AI TTS" +DEFAULT_AI_TASK_NAME = "Google AI Task" CONF_RECOMMENDED = "recommended" CONF_CHAT_MODEL = "chat_model" @@ -35,6 +36,7 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False TIMEOUT_MILLIS = 10000 FILE_POLLING_INTERVAL_SECONDS = 0.05 + RECOMMENDED_CONVERSATION_OPTIONS = { CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], @@ -44,3 +46,7 @@ RECOMMENDED_CONVERSATION_OPTIONS = { RECOMMENDED_TTS_OPTIONS = { CONF_RECOMMENDED: True, } + +RECOMMENDED_AI_TASK_OPTIONS = { + CONF_RECOMMENDED: True, +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index eef595ad05d..774f41f0279 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -88,6 +88,34 @@ "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } + }, + "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": { + "set_options": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "recommended": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::recommended%]", + "chat_model": "[%key:common::generic::model%]", + "temperature": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::temperature%]", + "top_p": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_p%]", + "top_k": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::top_k%]", + "max_tokens": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::max_tokens%]", + "harassment_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::harassment_block_threshold%]", + "hate_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::hate_block_threshold%]", + "sexual_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::sexual_block_threshold%]", + "dangerous_block_threshold": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::step::set_options::data::dangerous_block_threshold%]" + } + } + }, + "abort": { + "entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" + } } }, "services": { diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 331afc723ae..244ac518fbd 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.google_generative_ai_conversation.const import ( CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_TTS_NAME, ) @@ -29,6 +30,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "api_key": "bla", }, version=2, + minor_version=3, subentries_data=[ { "data": {}, @@ -44,6 +46,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: "subentry_id": "ulid-tts", "unique_id": None, }, + { + "data": {}, + "subentry_type": "ai_task_data", + "title": DEFAULT_AI_TASK_NAME, + "subentry_id": "ulid-ai-task", + "unique_id": None, + }, ], ) entry.runtime_data = Mock() diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr index 48091d83a00..bf44b1cbc04 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_diagnostics.ambr @@ -7,6 +7,14 @@ 'options': dict({ }), 'subentries': dict({ + 'ulid-ai-task': dict({ + 'data': dict({ + }), + 'subentry_id': 'ulid-ai-task', + 'subentry_type': 'ai_task_data', + 'title': 'Google AI Task', + 'unique_id': None, + }), 'ulid-conversation': dict({ 'data': dict({ 'chat_model': 'models/gemini-2.5-flash', diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr index 5722713bc56..a2603328959 100644 --- a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -32,6 +32,37 @@ 'sw_version': None, 'via_device_id': None, }), + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'config_entries_subentries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': , + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'google_generative_ai_conversation', + 'ulid-ai-task', + ), + }), + 'is_new': False, + 'labels': set({ + }), + 'manufacturer': 'Google', + 'model': 'gemini-2.5-flash', + 'model_id': None, + 'name': 'Google AI Task', + 'name_by_user': None, + 'primary_config_entry': , + 'serial_number': None, + 'suggested_area': None, + 'sw_version': None, + 'via_device_id': None, + }), DeviceRegistryEntrySnapshot({ 'area_id': None, 'config_entries': , diff --git a/tests/components/google_generative_ai_conversation/test_ai_task.py b/tests/components/google_generative_ai_conversation/test_ai_task.py new file mode 100644 index 00000000000..72b62b64615 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_ai_task.py @@ -0,0 +1,62 @@ +"""Test AI Task platform of Google Generative AI Conversation integration.""" + +from unittest.mock import AsyncMock + +from google.genai.types import GenerateContentResponse +import pytest + +from homeassistant.components import ai_task +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.conversation import ( + MockChatLog, + mock_chat_log, # noqa: F401 +) + + +@pytest.mark.usefixtures("mock_init_component") +async def test_run_task( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_chat_log: MockChatLog, # noqa: F811 + mock_send_message_stream: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test empty response.""" + entity_id = "ai_task.google_ai_task" + + # Ensure it's 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.config_entry_id == mock_config_entry.entry_id + assert entity_entry.config_subentry_id == ai_task_entry.subentry_id + + mock_send_message_stream.return_value = [ + [ + GenerateContentResponse( + candidates=[ + { + "content": { + "parts": [{"text": "Hi there!"}], + "role": "model", + }, + } + ], + ), + ], + ] + result = await ai_task.async_generate_data( + hass, + task_name="Test Task", + entity_id=entity_id, + instructions="Test prompt", + ) + assert result.data == "Hi there!" diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index a3fa487e1d3..bf3e2aedb45 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -19,9 +19,11 @@ from homeassistant.components.google_generative_ai_conversation.const import ( CONF_TOP_K, CONF_TOP_P, CONF_USE_GOOGLE_SEARCH_TOOL, + DEFAULT_AI_TASK_NAME, DEFAULT_CONVERSATION_NAME, DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, RECOMMENDED_CHAT_MODEL, RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_HARM_BLOCK_THRESHOLD, @@ -121,6 +123,12 @@ async def test_form(hass: HomeAssistant) -> None: "title": DEFAULT_TTS_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 @@ -222,7 +230,7 @@ async def test_creating_tts_subentry( assert result2["title"] == "Mock TTS" assert result2["data"] == RECOMMENDED_TTS_OPTIONS - assert len(mock_config_entry.subentries) == 3 + assert len(mock_config_entry.subentries) == 4 new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0] new_subentry = mock_config_entry.subentries[new_subentry_id] @@ -232,13 +240,59 @@ async def test_creating_tts_subentry( assert new_subentry.title == "Mock TTS" +async def test_creating_ai_task_subentry( + hass: HomeAssistant, + mock_init_component: None, + mock_config_entry: MockConfigEntry, +) -> None: + """Test creating an AI task subentry.""" + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result = await hass.config_entries.subentries.async_init( + (mock_config_entry.entry_id, "ai_task_data"), + context={"source": config_entries.SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM, result + assert result["step_id"] == "set_options" + assert not result["errors"] + + old_subentries = set(mock_config_entry.subentries) + + with patch( + "google.genai.models.AsyncModels.list", + return_value=get_models_pager(), + ): + result2 = await hass.config_entries.subentries.async_configure( + result["flow_id"], + {CONF_NAME: "Mock AI Task", **RECOMMENDED_AI_TASK_OPTIONS}, + ) + await hass.async_block_till_done() + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert result2["title"] == "Mock AI Task" + assert result2["data"] == RECOMMENDED_AI_TASK_OPTIONS + + assert len(mock_config_entry.subentries) == 4 + + 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.data == RECOMMENDED_AI_TASK_OPTIONS + assert new_subentry.title == "Mock AI Task" + + async def test_creating_conversation_subentry_not_loaded( hass: HomeAssistant, mock_init_component: None, mock_config_entry: MockConfigEntry, ) -> None: - """Test creating a conversation subentry.""" + """Test that subentry fails to init if entry not loaded.""" await hass.config_entries.async_unload(mock_config_entry.entry_id) + with patch( "google.genai.models.AsyncModels.list", return_value=get_models_pager(), diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index 9702aae4c9e..c0a610f6a0a 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -8,9 +8,13 @@ from requests.exceptions import Timeout from syrupy.assertion import SnapshotAssertion from homeassistant.components.google_generative_ai_conversation.const import ( + DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DEFAULT_TITLE, DEFAULT_TTS_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_TTS_OPTIONS, ) from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData @@ -397,7 +401,7 @@ async def test_load_entry_with_unloaded_entries( assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot -async def test_migration_from_v1_to_v2( +async def test_migration_from_v1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, @@ -473,10 +477,10 @@ async def test_migration_from_v1_to_v2( 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 == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -495,6 +499,14 @@ async def test_migration_from_v1_to_v2( assert len(tts_subentries) == 1 assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME subentry = conversation_subentries[0] @@ -542,7 +554,7 @@ 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, @@ -619,10 +631,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for entry in entries: assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == DEFAULT_TITLE - assert len(entry.subentries) == 2 + assert len(entry.subentries) == 3 subentry = list(entry.subentries.values())[0] assert subentry.subentry_type == "conversation" assert subentry.data == options @@ -631,6 +643,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( assert subentry.subentry_type == "tts" assert subentry.data == RECOMMENDED_TTS_OPTIONS assert subentry.title == DEFAULT_TTS_NAME + subentry = list(entry.subentries.values())[2] + assert subentry.subentry_type == "ai_task_data" + assert subentry.data == RECOMMENDED_AI_TASK_OPTIONS + assert subentry.title == DEFAULT_AI_TASK_NAME dev = device_registry.async_get_device( identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} @@ -642,7 +658,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( } -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, @@ -718,10 +734,10 @@ async def test_migration_from_v1_to_v2_with_same_keys( 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 == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -740,6 +756,14 @@ async def test_migration_from_v1_to_v2_with_same_keys( assert len(tts_subentries) == 1 assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME subentry = conversation_subentries[0] @@ -829,7 +853,7 @@ 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, @@ -837,12 +861,13 @@ async def test_migration_from_v2_1_to_v2_2( extra_subentries: list[ConfigSubentryData], expected_device_subentries: dict[str, set[str | None]], ) -> 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: + 2025.7.0b0-2025.7.0b1 and add AI Task subentry: - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2) - Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) + - Add AI Task subentry (Added in version 2.3) """ # Create a v2.1 config entry with 2 subentries, devices and entities options = { @@ -930,10 +955,10 @@ 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 == DEFAULT_TITLE - assert len(entry.subentries) == 3 + assert len(entry.subentries) == 4 conversation_subentries = [ subentry for subentry in entry.subentries.values() @@ -952,6 +977,14 @@ async def test_migration_from_v2_1_to_v2_2( assert len(tts_subentries) == 1 assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].title == DEFAULT_TTS_NAME + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME subentry = conversation_subentries[0] @@ -1011,3 +1044,80 @@ async def test_devices( device_registry, mock_config_entry.entry_id ) assert devices == snapshot + + +async def test_migrate_entry_from_v2_2(hass: HomeAssistant) -> None: + """Test migration from version 2.2.""" + # Create a v2.2 config entry with conversation and TTS subentries + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + version=2, + minor_version=2, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + { + "data": RECOMMENDED_TTS_OPTIONS, + "subentry_type": "tts", + "title": DEFAULT_TTS_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + + # Run setup to trigger migration + with patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is True + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == 3 + + # Check we now have conversation, tts and ai_task_data subentries + assert len(entry.subentries) == 3 + + subentries = { + subentry.subentry_type: subentry for subentry in entry.subentries.values() + } + assert "conversation" in subentries + assert "tts" in subentries + assert "ai_task_data" in subentries + + # Find and verify the ai_task_data subentry + ai_task_subentry = subentries["ai_task_data"] + assert ai_task_subentry is not None + assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME + assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS + + # Verify conversation subentry is still there and unchanged + conversation_subentry = subentries["conversation"] + assert conversation_subentry is not None + assert conversation_subentry.title == DEFAULT_CONVERSATION_NAME + assert conversation_subentry.data == RECOMMENDED_CONVERSATION_OPTIONS + + # Verify TTS subentry is still there and unchanged + tts_subentry = subentries["tts"] + assert tts_subentry is not None + assert tts_subentry.title == DEFAULT_TTS_NAME + assert tts_subentry.data == RECOMMENDED_TTS_OPTIONS From 8641a2141c0e0343e6aa580a6294f659d738f311 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 4 Jul 2025 01:10:21 -0700 Subject: [PATCH 8/9] Fix has-entity-name and entity-translations in Opower (#148098) --- homeassistant/components/opower/sensor.py | 43 ++++++++-------- homeassistant/components/opower/strings.json | 52 ++++++++++++++++++++ tests/components/opower/test_sensor.py | 28 ++++++++--- 3 files changed, 94 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 46aa9e9b318..9fc4d7e536a 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OpowerConfigEntry, OpowerCoordinator +PARALLEL_UPDATES = 0 + @dataclass(frozen=True, kw_only=True) class OpowerEntityDescription(SensorEntityDescription): @@ -38,7 +40,7 @@ class OpowerEntityDescription(SensorEntityDescription): ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( OpowerEntityDescription( key="elec_usage_to_date", - name="Current bill electric usage to date", + translation_key="elec_usage_to_date", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, # Not TOTAL_INCREASING because it can decrease for accounts with solar @@ -48,7 +50,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_forecasted_usage", - name="Current bill electric forecasted usage", + translation_key="elec_forecasted_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -57,7 +59,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_typical_usage", - name="Typical monthly electric usage", + translation_key="elec_typical_usage", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL, @@ -66,7 +68,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_cost_to_date", - name="Current bill electric cost to date", + translation_key="elec_cost_to_date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -75,7 +77,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_forecasted_cost", - name="Current bill electric forecasted cost", + translation_key="elec_forecasted_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -84,7 +86,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_typical_cost", - name="Typical monthly electric cost", + translation_key="elec_typical_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -93,7 +95,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_start_date", - name="Current bill electric start date", + translation_key="elec_start_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -101,7 +103,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="elec_end_date", - name="Current bill electric end date", + translation_key="elec_end_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -111,7 +113,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( OpowerEntityDescription( key="gas_usage_to_date", - name="Current bill gas usage to date", + translation_key="gas_usage_to_date", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -120,7 +122,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_forecasted_usage", - name="Current bill gas forecasted usage", + translation_key="gas_forecasted_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -129,7 +131,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_typical_usage", - name="Typical monthly gas usage", + translation_key="gas_typical_usage", device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, state_class=SensorStateClass.TOTAL, @@ -138,7 +140,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_cost_to_date", - name="Current bill gas cost to date", + translation_key="gas_cost_to_date", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -147,7 +149,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_forecasted_cost", - name="Current bill gas forecasted cost", + translation_key="gas_forecasted_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -156,7 +158,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_typical_cost", - name="Typical monthly gas cost", + translation_key="gas_typical_cost", device_class=SensorDeviceClass.MONETARY, native_unit_of_measurement="USD", state_class=SensorStateClass.TOTAL, @@ -165,7 +167,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_start_date", - name="Current bill gas start date", + translation_key="gas_start_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -173,7 +175,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( ), OpowerEntityDescription( key="gas_end_date", - name="Current bill gas end date", + translation_key="gas_end_date", device_class=SensorDeviceClass.DATE, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -229,6 +231,7 @@ async def async_setup_entry( class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): """Representation of an Opower sensor.""" + _attr_has_entity_name = True entity_description: OpowerEntityDescription def __init__( @@ -249,8 +252,6 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): @property def native_value(self) -> StateType | date: """Return the state.""" - if self.coordinator.data is not None: - return self.entity_description.value_fn( - self.coordinator.data[self.utility_account_id] - ) - return None + return self.entity_description.value_fn( + self.coordinator.data[self.utility_account_id] + ) diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 3af968cf789..cd22bd8d7a1 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -37,5 +37,57 @@ "title": "Return to grid statistics for account: {utility_account_id}", "description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue." } + }, + "entity": { + "sensor": { + "elec_usage_to_date": { + "name": "Current bill electric usage to date" + }, + "elec_forecasted_usage": { + "name": "Current bill electric forecasted usage" + }, + "elec_typical_usage": { + "name": "Typical monthly electric usage" + }, + "elec_cost_to_date": { + "name": "Current bill electric cost to date" + }, + "elec_forecasted_cost": { + "name": "Current bill electric forecasted cost" + }, + "elec_typical_cost": { + "name": "Typical monthly electric cost" + }, + "elec_start_date": { + "name": "Current bill electric start date" + }, + "elec_end_date": { + "name": "Current bill electric end date" + }, + "gas_usage_to_date": { + "name": "Current bill gas usage to date" + }, + "gas_forecasted_usage": { + "name": "Current bill gas forecasted usage" + }, + "gas_typical_usage": { + "name": "Typical monthly gas usage" + }, + "gas_cost_to_date": { + "name": "Current bill gas cost to date" + }, + "gas_forecasted_cost": { + "name": "Current bill gas forecasted cost" + }, + "gas_typical_cost": { + "name": "Typical monthly gas cost" + }, + "gas_start_date": { + "name": "Current bill gas start date" + }, + "gas_end_date": { + "name": "Current bill gas end date" + } + } } } diff --git a/tests/components/opower/test_sensor.py b/tests/components/opower/test_sensor.py index 91ffb271b2b..883bf86f883 100644 --- a/tests/components/opower/test_sensor.py +++ b/tests/components/opower/test_sensor.py @@ -25,36 +25,48 @@ async def test_sensors( entity_registry = er.async_get(hass) # Check electric sensors - entry = entity_registry.async_get("sensor.current_bill_electric_usage_to_date") + entry = entity_registry.async_get( + "sensor.elec_account_111111_current_bill_electric_usage_to_date" + ) assert entry assert entry.unique_id == "pge_111111_elec_usage_to_date" - state = hass.states.get("sensor.current_bill_electric_usage_to_date") + state = hass.states.get( + "sensor.elec_account_111111_current_bill_electric_usage_to_date" + ) assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.state == "100" - entry = entity_registry.async_get("sensor.current_bill_electric_cost_to_date") + entry = entity_registry.async_get( + "sensor.elec_account_111111_current_bill_electric_cost_to_date" + ) assert entry assert entry.unique_id == "pge_111111_elec_cost_to_date" - state = hass.states.get("sensor.current_bill_electric_cost_to_date") + state = hass.states.get( + "sensor.elec_account_111111_current_bill_electric_cost_to_date" + ) assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.state == "20.0" # Check gas sensors - entry = entity_registry.async_get("sensor.current_bill_gas_usage_to_date") + entry = entity_registry.async_get( + "sensor.gas_account_222222_current_bill_gas_usage_to_date" + ) assert entry assert entry.unique_id == "pge_222222_gas_usage_to_date" - state = hass.states.get("sensor.current_bill_gas_usage_to_date") + state = hass.states.get("sensor.gas_account_222222_current_bill_gas_usage_to_date") assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS # Convert 50 CCF to m³ assert float(state.state) == pytest.approx(50 * 2.83168, abs=1e-3) - entry = entity_registry.async_get("sensor.current_bill_gas_cost_to_date") + entry = entity_registry.async_get( + "sensor.gas_account_222222_current_bill_gas_cost_to_date" + ) assert entry assert entry.unique_id == "pge_222222_gas_cost_to_date" - state = hass.states.get("sensor.current_bill_gas_cost_to_date") + state = hass.states.get("sensor.gas_account_222222_current_bill_gas_cost_to_date") assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.state == "15.0" From 1fc624c7a7cef1e6745bb98eeb3355be6dae17ad Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 4 Jul 2025 04:05:16 -0700 Subject: [PATCH 9/9] Update LLM selector serializer to support ObjectSelector fields and arrays (#148094) --- homeassistant/helpers/llm.py | 18 +++++++++++- tests/helpers/test_llm.py | 53 ++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 5d9e4c3bdef..bf89e693870 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -777,7 +777,23 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901 return result if isinstance(schema, selector.ObjectSelector): - return {"type": "object", "additionalProperties": True} + result = {"type": "object"} + if fields := schema.config.get("fields"): + result["properties"] = { + field: convert( + selector.selector(field_schema["selector"]), + custom_serializer=_selector_serializer, + ) + for field, field_schema in fields.items() + } + else: + result["additionalProperties"] = True + if schema.config.get("multiple"): + result = { + "type": "array", + "items": result, + } + return result if isinstance(schema, selector.SelectSelector): options = [ diff --git a/tests/helpers/test_llm.py b/tests/helpers/test_llm.py index b6894505534..b978559130c 100644 --- a/tests/helpers/test_llm.py +++ b/tests/helpers/test_llm.py @@ -1139,6 +1139,59 @@ async def test_selector_serializer( "type": "object", "additionalProperties": True, } + assert selector_serializer( + selector.ObjectSelector( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {"min": 30, "max": 100}}, + }, + }, + "multiple": False, + "label_field": "name", + }, + ) + ) == { + "type": "object", + "properties": { + "name": {"type": "string"}, + "percentage": {"type": "number", "minimum": 30, "maximum": 100}, + }, + } + assert selector_serializer( + selector.ObjectSelector( + { + "fields": { + "name": { + "required": True, + "selector": {"text": {}}, + }, + "percentage": { + "selector": {"number": {"min": 30, "max": 100}}, + }, + }, + "multiple": True, + "label_field": "name", + }, + ) + ) == { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "percentage": { + "type": "number", + "minimum": 30, + "maximum": 100, + }, + }, + }, + } assert selector_serializer( selector.SelectSelector( {