diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 346d5322b02829..99e475a376bba2 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 .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 @@ 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 00000000000000..ab34af71ebeec4 --- /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 ade326cf71bbab..a68ca09e76da4c 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 @@ 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 @@ ) -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 @@ async def async_step_api( 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 @@ async def async_step_api( "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 @@ def async_get_supported_subentry_types( return { "conversation": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler, + "ai_task_data": LLMSubentryFlowHandler, } @@ -214,6 +226,8 @@ async def async_step_set_options( 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 72665cd34370e2..e7c5ba6bd22cd8 100644 --- a/homeassistant/components/google_generative_ai_conversation/const.py +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -12,6 +12,7 @@ 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 @@ 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_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 eef595ad05d176..774f41f027904b 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/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 46aa9e9b318fa0..9fc4d7e536a2ee 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -24,6 +24,8 @@ 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ class OpowerEntityDescription(SensorEntityDescription): ), 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 @@ def __init__( @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 3af968cf7895ed..cd22bd8d7a11a0 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/homeassistant/loader.py b/homeassistant/loader.py index ae3709e383b75b..a66a09d74070c7 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/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 331afc723aecac..244ac518fbd863 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -7,6 +7,7 @@ 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 48091d83a009b7..bf44b1cbc04d11 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 5722713bc563f8..a2603328959856 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 00000000000000..72b62b64615828 --- /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 a3fa487e1d3b61..bf3e2aedb45cb2 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 @@ 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 9702aae4c9eed9..c0a610f6a0ab88 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 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 diff --git a/tests/components/opower/test_sensor.py b/tests/components/opower/test_sensor.py index 91ffb271b2bfca..883bf86f8831b9 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" diff --git a/tests/test_loader.py b/tests/test_loader.py index 2d5ad76aa8af48..c67b520c7dcd20 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -1143,10 +1143,10 @@ async def test_loggers(hass: HomeAssistant) -> None: ("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 @@ async def test_loggers(hass: HomeAssistant) -> None: ("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),