Merge branch 'dev' into ai-task-structured-data

This commit is contained in:
Paulus Schoutsen 2025-07-04 13:11:00 +02:00 committed by GitHub
commit e42038742a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 755 additions and 75 deletions

View File

@ -16,6 +16,7 @@ import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_REAUTH, SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlow, OptionsFlow,
@ -40,12 +41,6 @@ APPS_NEW_ID = "NewApp"
CONF_APP_DELETE = "app_delete" CONF_APP_DELETE = "app_delete"
CONF_APP_ID = "app_id" CONF_APP_ID = "app_id"
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required("host"): str,
}
)
STEP_PAIR_DATA_SCHEMA = vol.Schema( STEP_PAIR_DATA_SCHEMA = vol.Schema(
{ {
vol.Required("pin"): str, vol.Required("pin"): str,
@ -66,7 +61,7 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_user( async def async_step_user(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle the initial step.""" """Handle the initial and reconfigure step."""
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
self.host = user_input[CONF_HOST] self.host = user_input[CONF_HOST]
@ -75,15 +70,32 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
await api.async_generate_cert_if_missing() await api.async_generate_cert_if_missing()
self.name, self.mac = await api.async_get_name_and_mac() self.name, self.mac = await api.async_get_name_and_mac()
await self.async_set_unique_id(format_mac(self.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}) self._abort_if_unique_id_configured(updates={CONF_HOST: self.host})
return await self._async_start_pair() return await self._async_start_pair()
except (CannotConnect, ConnectionClosed): except (CannotConnect, ConnectionClosed):
# Likely invalid IP address or device is network unreachable. Stay # Likely invalid IP address or device is network unreachable. Stay
# in the user step allowing the user to enter a different host. # in the user step allowing the user to enter a different host.
errors["base"] = "cannot_connect" 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( return self.async_show_form(
step_id="user", step_id="reconfigure" if self.source == SOURCE_RECONFIGURE else "user",
data_schema=STEP_USER_DATA_SCHEMA, data_schema=vol.Schema(
{vol.Required(CONF_HOST, default=default_host): str}
),
errors=errors, errors=errors,
) )
@ -216,6 +228,12 @@ class AndroidTVRemoteConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors, 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 @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(

View File

@ -11,6 +11,15 @@
"host": "The hostname or IP address of the Android TV device." "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": { "zeroconf_confirm": {
"title": "Discovered Android TV", "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." "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": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "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": { "options": {

View File

@ -63,6 +63,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
coordinator = entry.runtime_data coordinator = entry.runtime_data
coordinator.async_cancel_token_refresh() coordinator.async_cancel_token_refresh()
coordinator.async_cancel_firmware_refresh() coordinator.async_cancel_firmware_refresh()
coordinator.async_cancel_mac_verification()
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -7,7 +7,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"quality_scale": "platinum", "quality_scale": "platinum",
"requirements": ["pyenphase==2.1.0"], "requirements": ["pyenphase==2.2.0"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from functools import partial
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
from types import MappingProxyType from types import MappingProxyType
@ -37,11 +38,13 @@ from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
CONF_PROMPT, CONF_PROMPT,
DEFAULT_AI_TASK_NAME,
DEFAULT_TITLE, DEFAULT_TITLE,
DEFAULT_TTS_NAME, DEFAULT_TTS_NAME,
DOMAIN, DOMAIN,
FILE_POLLING_INTERVAL_SECONDS, FILE_POLLING_INTERVAL_SECONDS,
LOGGER, LOGGER,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_TTS_OPTIONS, RECOMMENDED_TTS_OPTIONS,
TIMEOUT_MILLIS, TIMEOUT_MILLIS,
@ -53,6 +56,7 @@ CONF_FILENAMES = "filenames"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = ( PLATFORMS = (
Platform.AI_TASK,
Platform.CONVERSATION, Platform.CONVERSATION,
Platform.TTS, Platform.TTS,
) )
@ -187,11 +191,9 @@ async def async_setup_entry(
"""Set up Google Generative AI Conversation from a config entry.""" """Set up Google Generative AI Conversation from a config entry."""
try: try:
client = await hass.async_add_executor_job(
def _init_client() -> Client: partial(Client, api_key=entry.data[CONF_API_KEY])
return Client(api_key=entry.data[CONF_API_KEY]) )
client = await hass.async_add_executor_job(_init_client)
await client.aio.models.get( await client.aio.models.get(
model=RECOMMENDED_CHAT_MODEL, model=RECOMMENDED_CHAT_MODEL,
config={"http_options": {"timeout": TIMEOUT_MILLIS}}, 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) 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( LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version "Migration to version %s:%s successful", entry.version, entry.minor_version
) )

View File

@ -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 "",
)

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from functools import partial
import logging import logging
from typing import Any, cast from typing import Any, cast
@ -46,10 +47,12 @@ from .const import (
CONF_TOP_K, CONF_TOP_K,
CONF_TOP_P, CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL, CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME, DEFAULT_CONVERSATION_NAME,
DEFAULT_TITLE, DEFAULT_TITLE,
DEFAULT_TTS_NAME, DEFAULT_TTS_NAME,
DOMAIN, DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD, 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. """Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. 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( await client.aio.models.list(
config={ config={
"http_options": { "http_options": {
@ -92,7 +97,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Google Generative AI Conversation.""" """Handle a config flow for Google Generative AI Conversation."""
VERSION = 2 VERSION = 2
MINOR_VERSION = 2 MINOR_VERSION = 3
async def async_step_api( async def async_step_api(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
@ -102,7 +107,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
self._async_abort_entries_match(user_input) self._async_abort_entries_match(user_input)
try: try:
await validate_input(user_input) await validate_input(self.hass, user_input)
except (APIError, Timeout) as err: except (APIError, Timeout) as err:
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err): if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
@ -133,6 +138,12 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
"title": DEFAULT_TTS_NAME, "title": DEFAULT_TTS_NAME,
"unique_id": None, "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( return self.async_show_form(
@ -181,6 +192,7 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
return { return {
"conversation": LLMSubentryFlowHandler, "conversation": LLMSubentryFlowHandler,
"tts": LLMSubentryFlowHandler, "tts": LLMSubentryFlowHandler,
"ai_task_data": LLMSubentryFlowHandler,
} }
@ -214,6 +226,8 @@ class LLMSubentryFlowHandler(ConfigSubentryFlow):
options: dict[str, Any] options: dict[str, Any]
if self._subentry_type == "tts": if self._subentry_type == "tts":
options = RECOMMENDED_TTS_OPTIONS.copy() options = RECOMMENDED_TTS_OPTIONS.copy()
elif self._subentry_type == "ai_task_data":
options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else: else:
options = RECOMMENDED_CONVERSATION_OPTIONS.copy() options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
else: else:
@ -288,6 +302,8 @@ async def google_generative_ai_config_option_schema(
default_name = options[CONF_NAME] default_name = options[CONF_NAME]
elif subentry_type == "tts": elif subentry_type == "tts":
default_name = DEFAULT_TTS_NAME default_name = DEFAULT_TTS_NAME
elif subentry_type == "ai_task_data":
default_name = DEFAULT_AI_TASK_NAME
else: else:
default_name = DEFAULT_CONVERSATION_NAME default_name = DEFAULT_CONVERSATION_NAME
schema: dict[vol.Required | vol.Optional, Any] = { schema: dict[vol.Required | vol.Optional, Any] = {
@ -315,6 +331,7 @@ async def google_generative_ai_config_option_schema(
), ),
} }
) )
schema.update( schema.update(
{ {
vol.Required( vol.Required(
@ -443,4 +460,5 @@ async def google_generative_ai_config_option_schema(
): bool, ): bool,
} }
) )
return schema return schema

View File

@ -12,6 +12,7 @@ CONF_PROMPT = "prompt"
DEFAULT_CONVERSATION_NAME = "Google AI Conversation" DEFAULT_CONVERSATION_NAME = "Google AI Conversation"
DEFAULT_TTS_NAME = "Google AI TTS" DEFAULT_TTS_NAME = "Google AI TTS"
DEFAULT_AI_TASK_NAME = "Google AI Task"
CONF_RECOMMENDED = "recommended" CONF_RECOMMENDED = "recommended"
CONF_CHAT_MODEL = "chat_model" CONF_CHAT_MODEL = "chat_model"
@ -35,6 +36,7 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
TIMEOUT_MILLIS = 10000 TIMEOUT_MILLIS = 10000
FILE_POLLING_INTERVAL_SECONDS = 0.05 FILE_POLLING_INTERVAL_SECONDS = 0.05
RECOMMENDED_CONVERSATION_OPTIONS = { RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT, CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST], CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
@ -44,3 +46,7 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
RECOMMENDED_TTS_OPTIONS = { RECOMMENDED_TTS_OPTIONS = {
CONF_RECOMMENDED: True, CONF_RECOMMENDED: True,
} }
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}

View File

@ -88,6 +88,34 @@
"entry_not_loaded": "[%key:component::google_generative_ai_conversation::config_subentries::conversation::abort::entry_not_loaded%]", "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%]" "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": { "services": {

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from phone_modem import PhoneModem 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.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE from homeassistant.const import EVENT_HOMEASSISTANT_STOP, STATE_IDLE
from homeassistant.core import Event, HomeAssistant, callback 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.""" """Implementation of USB modem caller ID sensor."""
_attr_should_poll = False _attr_should_poll = False
@ -62,9 +62,21 @@ class ModemCalleridSensor(SensorEntity):
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Call when the modem sensor is added to Home Assistant.""" """Call when the modem sensor is added to Home Assistant."""
self.api.registercallback(self._async_incoming_call)
await super().async_added_to_hass() 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 @callback
def _async_incoming_call(self, new_state: str) -> None: def _async_incoming_call(self, new_state: str) -> None:
"""Handle new states.""" """Handle new states."""

View File

@ -24,6 +24,8 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import OpowerConfigEntry, OpowerCoordinator from .coordinator import OpowerConfigEntry, OpowerCoordinator
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
class OpowerEntityDescription(SensorEntityDescription): class OpowerEntityDescription(SensorEntityDescription):
@ -38,7 +40,7 @@ class OpowerEntityDescription(SensorEntityDescription):
ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = ( ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
OpowerEntityDescription( OpowerEntityDescription(
key="elec_usage_to_date", key="elec_usage_to_date",
name="Current bill electric usage to date", translation_key="elec_usage_to_date",
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
# Not TOTAL_INCREASING because it can decrease for accounts with solar # Not TOTAL_INCREASING because it can decrease for accounts with solar
@ -48,7 +50,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_forecasted_usage", key="elec_forecasted_usage",
name="Current bill electric forecasted usage", translation_key="elec_forecasted_usage",
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -57,7 +59,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_typical_usage", key="elec_typical_usage",
name="Typical monthly electric usage", translation_key="elec_typical_usage",
device_class=SensorDeviceClass.ENERGY, device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -66,7 +68,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_cost_to_date", key="elec_cost_to_date",
name="Current bill electric cost to date", translation_key="elec_cost_to_date",
device_class=SensorDeviceClass.MONETARY, device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD", native_unit_of_measurement="USD",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -75,7 +77,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_forecasted_cost", key="elec_forecasted_cost",
name="Current bill electric forecasted cost", translation_key="elec_forecasted_cost",
device_class=SensorDeviceClass.MONETARY, device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD", native_unit_of_measurement="USD",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -84,7 +86,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_typical_cost", key="elec_typical_cost",
name="Typical monthly electric cost", translation_key="elec_typical_cost",
device_class=SensorDeviceClass.MONETARY, device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD", native_unit_of_measurement="USD",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -93,7 +95,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_start_date", key="elec_start_date",
name="Current bill electric start date", translation_key="elec_start_date",
device_class=SensorDeviceClass.DATE, device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -101,7 +103,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="elec_end_date", key="elec_end_date",
name="Current bill electric end date", translation_key="elec_end_date",
device_class=SensorDeviceClass.DATE, device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -111,7 +113,7 @@ ELEC_SENSORS: tuple[OpowerEntityDescription, ...] = (
GAS_SENSORS: tuple[OpowerEntityDescription, ...] = ( GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
OpowerEntityDescription( OpowerEntityDescription(
key="gas_usage_to_date", key="gas_usage_to_date",
name="Current bill gas usage to date", translation_key="gas_usage_to_date",
device_class=SensorDeviceClass.GAS, device_class=SensorDeviceClass.GAS,
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -120,7 +122,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_forecasted_usage", key="gas_forecasted_usage",
name="Current bill gas forecasted usage", translation_key="gas_forecasted_usage",
device_class=SensorDeviceClass.GAS, device_class=SensorDeviceClass.GAS,
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -129,7 +131,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_typical_usage", key="gas_typical_usage",
name="Typical monthly gas usage", translation_key="gas_typical_usage",
device_class=SensorDeviceClass.GAS, device_class=SensorDeviceClass.GAS,
native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET, native_unit_of_measurement=UnitOfVolume.CENTUM_CUBIC_FEET,
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -138,7 +140,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_cost_to_date", key="gas_cost_to_date",
name="Current bill gas cost to date", translation_key="gas_cost_to_date",
device_class=SensorDeviceClass.MONETARY, device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD", native_unit_of_measurement="USD",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -147,7 +149,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_forecasted_cost", key="gas_forecasted_cost",
name="Current bill gas forecasted cost", translation_key="gas_forecasted_cost",
device_class=SensorDeviceClass.MONETARY, device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD", native_unit_of_measurement="USD",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -156,7 +158,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_typical_cost", key="gas_typical_cost",
name="Typical monthly gas cost", translation_key="gas_typical_cost",
device_class=SensorDeviceClass.MONETARY, device_class=SensorDeviceClass.MONETARY,
native_unit_of_measurement="USD", native_unit_of_measurement="USD",
state_class=SensorStateClass.TOTAL, state_class=SensorStateClass.TOTAL,
@ -165,7 +167,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_start_date", key="gas_start_date",
name="Current bill gas start date", translation_key="gas_start_date",
device_class=SensorDeviceClass.DATE, device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -173,7 +175,7 @@ GAS_SENSORS: tuple[OpowerEntityDescription, ...] = (
), ),
OpowerEntityDescription( OpowerEntityDescription(
key="gas_end_date", key="gas_end_date",
name="Current bill gas end date", translation_key="gas_end_date",
device_class=SensorDeviceClass.DATE, device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC, entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False, entity_registry_enabled_default=False,
@ -229,6 +231,7 @@ async def async_setup_entry(
class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity): class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
"""Representation of an Opower sensor.""" """Representation of an Opower sensor."""
_attr_has_entity_name = True
entity_description: OpowerEntityDescription entity_description: OpowerEntityDescription
def __init__( def __init__(
@ -249,8 +252,6 @@ class OpowerSensor(CoordinatorEntity[OpowerCoordinator], SensorEntity):
@property @property
def native_value(self) -> StateType | date: def native_value(self) -> StateType | date:
"""Return the state.""" """Return the state."""
if self.coordinator.data is not None: return self.entity_description.value_fn(
return self.entity_description.value_fn( self.coordinator.data[self.utility_account_id]
self.coordinator.data[self.utility_account_id] )
)
return None

View File

@ -37,5 +37,57 @@
"title": "Return to grid statistics for account: {utility_account_id}", "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." "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"
}
}
} }
} }

View File

@ -777,7 +777,23 @@ def _selector_serializer(schema: Any) -> Any: # noqa: C901
return result return result
if isinstance(schema, selector.ObjectSelector): 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): if isinstance(schema, selector.SelectSelector):
options = [ options = [

View File

@ -1789,6 +1789,13 @@ def async_get_issue_tracker(
# If we know nothing about the integration, suggest opening an issue on HA core # If we know nothing about the integration, suggest opening an issue on HA core
return issue_tracker 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: if not integration:
integration = async_get_issue_integration(hass, integration_domain) integration = async_get_issue_integration(hass, integration_domain)

2
requirements_all.txt generated
View File

@ -1962,7 +1962,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1 pyemoncms==0.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==2.1.0 pyenphase==2.2.0
# homeassistant.components.envisalink # homeassistant.components.envisalink
pyenvisalink==4.7 pyenvisalink==4.7

View File

@ -1637,7 +1637,7 @@ pyeiscp==0.0.7
pyemoncms==0.1.1 pyemoncms==0.1.1
# homeassistant.components.enphase_envoy # homeassistant.components.enphase_envoy
pyenphase==2.1.0 pyenphase==2.2.0
# homeassistant.components.everlights # homeassistant.components.everlights
pyeverlights==0.1.0 pyeverlights==0.1.0

View File

@ -1069,3 +1069,100 @@ async def test_options_flow(
) )
assert result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert mock_config_entry.options == {CONF_ENABLE_IME: True} 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

View File

@ -129,6 +129,7 @@ async def test_async_step_reauth(
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
}, },
) )
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful" assert result["reason"] == "reauth_successful"
@ -639,6 +640,7 @@ async def test_reauth_errors(
CONF_PASSWORD: "test-password", CONF_PASSWORD: "test-password",
}, },
) )
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reauth_successful" assert result["reason"] == "reauth_successful"

View File

@ -7,6 +7,7 @@ import pytest
from homeassistant.components.google_generative_ai_conversation.const import ( from homeassistant.components.google_generative_ai_conversation.const import (
CONF_USE_GOOGLE_SEARCH_TOOL, CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME, DEFAULT_CONVERSATION_NAME,
DEFAULT_TTS_NAME, DEFAULT_TTS_NAME,
) )
@ -29,6 +30,7 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"api_key": "bla", "api_key": "bla",
}, },
version=2, version=2,
minor_version=3,
subentries_data=[ subentries_data=[
{ {
"data": {}, "data": {},
@ -44,6 +46,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"subentry_id": "ulid-tts", "subentry_id": "ulid-tts",
"unique_id": None, "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() entry.runtime_data = Mock()

View File

@ -7,6 +7,14 @@
'options': dict({ 'options': dict({
}), }),
'subentries': 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({ 'ulid-conversation': dict({
'data': dict({ 'data': dict({
'chat_model': 'models/gemini-2.5-flash', 'chat_model': 'models/gemini-2.5-flash',

View File

@ -32,6 +32,37 @@
'sw_version': None, 'sw_version': None,
'via_device_id': None, 'via_device_id': None,
}), }),
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': <DeviceEntryType.SERVICE: 'service'>,
'hw_version': None,
'id': <ANY>,
'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': <ANY>,
'serial_number': None,
'suggested_area': None,
'sw_version': None,
'via_device_id': None,
}),
DeviceRegistryEntrySnapshot({ DeviceRegistryEntrySnapshot({
'area_id': None, 'area_id': None,
'config_entries': <ANY>, 'config_entries': <ANY>,

View File

@ -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!"

View File

@ -19,9 +19,11 @@ from homeassistant.components.google_generative_ai_conversation.const import (
CONF_TOP_K, CONF_TOP_K,
CONF_TOP_P, CONF_TOP_P,
CONF_USE_GOOGLE_SEARCH_TOOL, CONF_USE_GOOGLE_SEARCH_TOOL,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME, DEFAULT_CONVERSATION_NAME,
DEFAULT_TTS_NAME, DEFAULT_TTS_NAME,
DOMAIN, DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CHAT_MODEL, RECOMMENDED_CHAT_MODEL,
RECOMMENDED_CONVERSATION_OPTIONS, RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_HARM_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD,
@ -121,6 +123,12 @@ async def test_form(hass: HomeAssistant) -> None:
"title": DEFAULT_TTS_NAME, "title": DEFAULT_TTS_NAME,
"unique_id": None, "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 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["title"] == "Mock TTS"
assert result2["data"] == RECOMMENDED_TTS_OPTIONS 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_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
new_subentry = mock_config_entry.subentries[new_subentry_id] 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" 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( async def test_creating_conversation_subentry_not_loaded(
hass: HomeAssistant, hass: HomeAssistant,
mock_init_component: None, mock_init_component: None,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> 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) await hass.config_entries.async_unload(mock_config_entry.entry_id)
with patch( with patch(
"google.genai.models.AsyncModels.list", "google.genai.models.AsyncModels.list",
return_value=get_models_pager(), return_value=get_models_pager(),

View File

@ -8,9 +8,13 @@ from requests.exceptions import Timeout
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
from homeassistant.components.google_generative_ai_conversation.const import ( from homeassistant.components.google_generative_ai_conversation.const import (
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_TITLE, DEFAULT_TITLE,
DEFAULT_TTS_NAME, DEFAULT_TTS_NAME,
DOMAIN, DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CONVERSATION_OPTIONS,
RECOMMENDED_TTS_OPTIONS, RECOMMENDED_TTS_OPTIONS,
) )
from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData 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 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, hass: HomeAssistant,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
@ -473,10 +477,10 @@ async def test_migration_from_v1_to_v2(
assert len(entries) == 1 assert len(entries) == 1
entry = entries[0] entry = entries[0]
assert entry.version == 2 assert entry.version == 2
assert entry.minor_version == 2 assert entry.minor_version == 3
assert not entry.options assert not entry.options
assert entry.title == DEFAULT_TITLE assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 3 assert len(entry.subentries) == 4
conversation_subentries = [ conversation_subentries = [
subentry subentry
for subentry in entry.subentries.values() for subentry in entry.subentries.values()
@ -495,6 +499,14 @@ async def test_migration_from_v1_to_v2(
assert len(tts_subentries) == 1 assert len(tts_subentries) == 1
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
assert tts_subentries[0].title == DEFAULT_TTS_NAME 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] 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, hass: HomeAssistant,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
@ -619,10 +631,10 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
for entry in entries: for entry in entries:
assert entry.version == 2 assert entry.version == 2
assert entry.minor_version == 2 assert entry.minor_version == 3
assert not entry.options assert not entry.options
assert entry.title == DEFAULT_TITLE assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 2 assert len(entry.subentries) == 3
subentry = list(entry.subentries.values())[0] subentry = list(entry.subentries.values())[0]
assert subentry.subentry_type == "conversation" assert subentry.subentry_type == "conversation"
assert subentry.data == options 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.subentry_type == "tts"
assert subentry.data == RECOMMENDED_TTS_OPTIONS assert subentry.data == RECOMMENDED_TTS_OPTIONS
assert subentry.title == DEFAULT_TTS_NAME 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( dev = device_registry.async_get_device(
identifiers={(DOMAIN, list(entry.subentries.values())[0].subentry_id)} 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, hass: HomeAssistant,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
@ -718,10 +734,10 @@ async def test_migration_from_v1_to_v2_with_same_keys(
assert len(entries) == 1 assert len(entries) == 1
entry = entries[0] entry = entries[0]
assert entry.version == 2 assert entry.version == 2
assert entry.minor_version == 2 assert entry.minor_version == 3
assert not entry.options assert not entry.options
assert entry.title == DEFAULT_TITLE assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 3 assert len(entry.subentries) == 4
conversation_subentries = [ conversation_subentries = [
subentry subentry
for subentry in entry.subentries.values() 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 len(tts_subentries) == 1
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
assert tts_subentries[0].title == DEFAULT_TTS_NAME 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] 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, hass: HomeAssistant,
device_registry: dr.DeviceRegistry, device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry, entity_registry: er.EntityRegistry,
@ -837,12 +861,13 @@ async def test_migration_from_v2_1_to_v2_2(
extra_subentries: list[ConfigSubentryData], extra_subentries: list[ConfigSubentryData],
expected_device_subentries: dict[str, set[str | None]], expected_device_subentries: dict[str, set[str | None]],
) -> 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 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) - Fix device registry (Fixed in Home Assistant Core 2025.7.0b2)
- Add TTS subentry (Added in Home Assistant Core 2025.7.0b1) - 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 # Create a v2.1 config entry with 2 subentries, devices and entities
options = { options = {
@ -930,10 +955,10 @@ async def test_migration_from_v2_1_to_v2_2(
assert len(entries) == 1 assert len(entries) == 1
entry = entries[0] entry = entries[0]
assert entry.version == 2 assert entry.version == 2
assert entry.minor_version == 2 assert entry.minor_version == 3
assert not entry.options assert not entry.options
assert entry.title == DEFAULT_TITLE assert entry.title == DEFAULT_TITLE
assert len(entry.subentries) == 3 assert len(entry.subentries) == 4
conversation_subentries = [ conversation_subentries = [
subentry subentry
for subentry in entry.subentries.values() 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 len(tts_subentries) == 1
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
assert tts_subentries[0].title == DEFAULT_TTS_NAME 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] subentry = conversation_subentries[0]
@ -1011,3 +1044,80 @@ async def test_devices(
device_registry, mock_config_entry.entry_id device_registry, mock_config_entry.entry_id
) )
assert devices == snapshot 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

View File

@ -25,36 +25,48 @@ async def test_sensors(
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
# Check electric sensors # 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
assert entry.unique_id == "pge_111111_elec_usage_to_date" 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
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR
assert state.state == "100" 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
assert entry.unique_id == "pge_111111_elec_cost_to_date" 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
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD"
assert state.state == "20.0" assert state.state == "20.0"
# Check gas sensors # 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
assert entry.unique_id == "pge_222222_gas_usage_to_date" 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
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS
# Convert 50 CCF to m³ # Convert 50 CCF to m³
assert float(state.state) == pytest.approx(50 * 2.83168, abs=1e-3) 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
assert entry.unique_id == "pge_222222_gas_cost_to_date" 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
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "USD"
assert state.state == "15.0" assert state.state == "15.0"

View File

@ -1139,6 +1139,59 @@ async def test_selector_serializer(
"type": "object", "type": "object",
"additionalProperties": True, "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( assert selector_serializer(
selector.SelectSelector( selector.SelectSelector(
{ {

View File

@ -1143,10 +1143,10 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com"
("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE),
("hue", None, CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE),
("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), ("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_HUE),
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER),
("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE), ("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE),
# Loaded custom integration with known issue tracker # 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", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER),
("bla_custom", None, CUSTOM_ISSUE_TRACKER), ("bla_custom", None, CUSTOM_ISSUE_TRACKER),
# Loaded custom integration without known 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), ("bla_custom_no_tracker", None, None),
("hue", "custom_components.bla.sensor", None), ("hue", "custom_components.bla.sensor", None),
# Unloaded custom integration with known issue tracker # 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), ("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER),
# Unloaded custom integration without known issue tracker # Unloaded custom integration without known issue tracker
("bla_custom_not_loaded_no_tracker", None, None), ("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", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE),
("hue", None, CORE_ISSUE_TRACKER_HUE), ("hue", None, CORE_ISSUE_TRACKER_HUE),
("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), ("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_HUE),
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER),
("hue", "homeassistant.components.mqtt.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 # Custom integration with known issue tracker - can't find it without hass
("bla_custom", "custom_components.bla_custom.sensor", None), ("bla_custom", "custom_components.bla_custom.sensor", None),