forked from home-assistant/core
Compare commits
17 Commits
assist_ask
...
google-llm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00baa90c78 | ||
|
|
3db5e1b551 | ||
|
|
01459d0f35 | ||
|
|
818e86f16e | ||
|
|
c453eed32d | ||
|
|
79a9f34150 | ||
|
|
7442f7af28 | ||
|
|
2e5de732a7 | ||
|
|
9bcd74c449 | ||
|
|
ace18e540b | ||
|
|
65f897793d | ||
|
|
435c08685d | ||
|
|
95f292c43d | ||
|
|
9346c584c3 | ||
|
|
6738085391 | ||
|
|
d9e5bad55e | ||
|
|
f7429f3431 |
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.1.12"]
|
||||
"requirements": ["aioamazondevices==3.1.14"]
|
||||
}
|
||||
|
||||
@@ -133,7 +133,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
service_func=handle_ask_question,
|
||||
schema=vol.All(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
|
||||
@@ -138,7 +138,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
_is_announcing = False
|
||||
_extra_system_prompt: str | None = None
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_stt_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_attr_tts_options: dict[str, Any] | None = None
|
||||
_pipeline_task: asyncio.Task | None = None
|
||||
_ask_question_future: asyncio.Future[str | None] | None = None
|
||||
|
||||
@@ -86,17 +86,3 @@ ask_question:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
label_field: sentences
|
||||
description_field: id
|
||||
multiple: true
|
||||
translation_key: answers
|
||||
fields:
|
||||
id:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
sentences:
|
||||
required: true
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
||||
@@ -90,13 +90,5 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"answers": {
|
||||
"fields": {
|
||||
"id": "Answer ID",
|
||||
"sentences": "Sentences"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.3.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
|
||||
}
|
||||
|
||||
@@ -285,9 +285,9 @@ class EsphomeAssistSatellite(
|
||||
data_to_send = {"text": event.data["stt_output"]["text"]}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS:
|
||||
data_to_send = {
|
||||
"tts_start_streaming": bool(
|
||||
event.data and event.data.get("tts_start_streaming")
|
||||
),
|
||||
"tts_start_streaming": "1"
|
||||
if (event.data and event.data.get("tts_start_streaming"))
|
||||
else "0",
|
||||
}
|
||||
elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_END:
|
||||
assert event.data is not None
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from types import MappingProxyType
|
||||
|
||||
from google.genai import Client
|
||||
from google.genai.errors import APIError, ClientError
|
||||
@@ -12,7 +13,7 @@ from google.genai.types import File, FileState
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import (
|
||||
HomeAssistant,
|
||||
@@ -26,16 +27,19 @@ from homeassistant.exceptions import (
|
||||
ConfigEntryNotReady,
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_PROMPT,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
FILE_POLLING_INTERVAL_SECONDS,
|
||||
LOGGER,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
@@ -46,6 +50,7 @@ CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (
|
||||
Platform.AI_TASK,
|
||||
Platform.CONVERSATION,
|
||||
Platform.TTS,
|
||||
)
|
||||
@@ -209,3 +214,51 @@ async def async_unload_entry(
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version == 1:
|
||||
# Migrate from version 1 to version 2
|
||||
# Move conversation-specific options to a subentry
|
||||
conversation_subentry = ConfigSubentry(
|
||||
data=entry.options,
|
||||
subentry_type="conversation",
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
unique_id=None,
|
||||
)
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
conversation_subentry,
|
||||
)
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS),
|
||||
subentry_type="ai_task",
|
||||
title=DEFAULT_AI_TASK_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Migrate conversation entity to be linked to subentry
|
||||
ent_reg = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(ent_reg, entry.entry_id):
|
||||
if entity_entry.domain == Platform.CONVERSATION:
|
||||
ent_reg.async_update_entity(
|
||||
entity_entry.entity_id,
|
||||
config_subentry_id=conversation_subentry.subentry_id,
|
||||
new_unique_id=conversation_subentry.subentry_id,
|
||||
)
|
||||
break
|
||||
|
||||
# Remove options from the main entry
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
options={},
|
||||
version=2,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"""LLM 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 LLM Task entities."""
|
||||
entities = [
|
||||
GoogleGenerativeAILLMTaskEntity(config_entry, subentry)
|
||||
for subentry in config_entry.subentries.values()
|
||||
if subentry.subentry_type == "ai_task"
|
||||
]
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class GoogleGenerativeAILLMTaskEntity(
|
||||
ai_task.AITaskEntity,
|
||||
GoogleGenerativeAILLMBaseEntity,
|
||||
):
|
||||
"""Google Generative AI AI Task entity."""
|
||||
|
||||
_attr_supported_features = ai_task.AITaskEntityFeature.GENERATE_TEXT
|
||||
|
||||
async def _async_generate_text(
|
||||
self,
|
||||
task: ai_task.GenTextTask,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> ai_task.GenTextTaskResult:
|
||||
"""Handle a generate text 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.GenTextTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
text=chat_log.content[-1].content or "",
|
||||
)
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from google import genai
|
||||
@@ -17,10 +16,11 @@ from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
ConfigSubentryFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import llm
|
||||
from homeassistant.helpers.selector import (
|
||||
NumberSelector,
|
||||
@@ -45,8 +45,12 @@ from .const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
@@ -64,12 +68,6 @@ STEP_API_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
RECOMMENDED_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -90,7 +88,7 @@ async def validate_input(data: dict[str, Any]) -> None:
|
||||
class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Generative AI Conversation."""
|
||||
|
||||
VERSION = 1
|
||||
VERSION = 2
|
||||
|
||||
async def async_step_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -117,7 +115,20 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_create_entry(
|
||||
title="Google Generative AI",
|
||||
data=user_input,
|
||||
options=RECOMMENDED_OPTIONS,
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="api",
|
||||
@@ -156,58 +167,97 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def async_get_options_flow(
|
||||
config_entry: ConfigEntry,
|
||||
) -> OptionsFlow:
|
||||
"""Create the options flow."""
|
||||
return GoogleGenerativeAIOptionsFlow(config_entry)
|
||||
@classmethod
|
||||
@callback
|
||||
def async_get_supported_subentry_types(
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {
|
||||
"conversation": LLMSubentryFlowHandler,
|
||||
"ai_task": LLMSubentryFlowHandler,
|
||||
}
|
||||
|
||||
|
||||
class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
"""Google Generative AI config flow options handler."""
|
||||
class LLMSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Flow for managing conversation subentries."""
|
||||
|
||||
def __init__(self, config_entry: ConfigEntry) -> None:
|
||||
"""Initialize options flow."""
|
||||
self.last_rendered_recommended = config_entry.options.get(
|
||||
CONF_RECOMMENDED, False
|
||||
)
|
||||
self._genai_client = config_entry.runtime_data
|
||||
last_rendered_recommended = False
|
||||
is_new: bool
|
||||
start_data: dict[str, Any]
|
||||
|
||||
async def async_step_init(
|
||||
@property
|
||||
def _genai_client(self) -> genai.Client:
|
||||
"""Return the Google Generative AI client."""
|
||||
return self._get_entry().runtime_data
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Manage the options."""
|
||||
options: dict[str, Any] | MappingProxyType[str, Any] = self.config_entry.options
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.is_new = True
|
||||
if self._subentry_type == "ai_task":
|
||||
self.start_data = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
self.start_data = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_set_options()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Handle reconfiguration of a subentry."""
|
||||
self.is_new = False
|
||||
self.start_data = self._get_reconfigure_subentry().data.copy()
|
||||
return await self.async_step_set_options()
|
||||
|
||||
async def async_step_set_options(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Set conversation options."""
|
||||
options = self.start_data
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
if user_input[CONF_RECOMMENDED] == self.last_rendered_recommended:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
user_input.pop(CONF_LLM_HASS_API, None)
|
||||
# Don't allow to save options that enable the Google Search tool with an Assist API
|
||||
if not (
|
||||
user_input.get(CONF_LLM_HASS_API)
|
||||
and user_input.get(CONF_USE_GOOGLE_SEARCH_TOOL, False) is True
|
||||
):
|
||||
# Don't allow to save options that enable the Google Seearch tool with an Assist API
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
if self.is_new:
|
||||
return self.async_create_entry(
|
||||
title=user_input.pop(CONF_NAME),
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_update_and_abort(
|
||||
self._get_entry(),
|
||||
self._get_reconfigure_subentry(),
|
||||
data=user_input,
|
||||
)
|
||||
errors[CONF_USE_GOOGLE_SEARCH_TOOL] = "invalid_google_search_option"
|
||||
|
||||
# Re-render the options again, now with the recommended options shown/hidden
|
||||
self.last_rendered_recommended = user_input[CONF_RECOMMENDED]
|
||||
|
||||
options = user_input
|
||||
else:
|
||||
self.last_rendered_recommended = options.get(CONF_RECOMMENDED, False)
|
||||
|
||||
schema = await google_generative_ai_config_option_schema(
|
||||
self.hass, options, self._genai_client
|
||||
self.hass, self.is_new, self._subentry_type, options, self._genai_client
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="init", data_schema=vol.Schema(schema), errors=errors
|
||||
step_id="set_options", data_schema=vol.Schema(schema), errors=errors
|
||||
)
|
||||
|
||||
|
||||
async def google_generative_ai_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
is_new: bool,
|
||||
subentry_type: str,
|
||||
options: Mapping[str, Any],
|
||||
genai_client: genai.Client,
|
||||
) -> dict:
|
||||
@@ -224,29 +274,57 @@ async def google_generative_ai_config_option_schema(
|
||||
):
|
||||
suggested_llm_apis = [suggested_llm_apis]
|
||||
|
||||
schema = {
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": suggested_llm_apis},
|
||||
): SelectSelector(SelectSelectorConfig(options=hass_apis, multiple=True)),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
if is_new:
|
||||
if CONF_NAME in options:
|
||||
default_name = options[CONF_NAME]
|
||||
elif subentry_type == "ai_task":
|
||||
default_name = DEFAULT_AI_TASK_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
schema: dict[vol.Required | vol.Optional, Any] = {
|
||||
vol.Required(CONF_NAME, default=default_name): str,
|
||||
}
|
||||
else:
|
||||
schema = {}
|
||||
|
||||
if subentry_type == "conversation":
|
||||
schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_PROMPT,
|
||||
description={
|
||||
"suggested_value": options.get(
|
||||
CONF_PROMPT, llm.DEFAULT_INSTRUCTIONS_PROMPT
|
||||
)
|
||||
},
|
||||
): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
description={"suggested_value": suggested_llm_apis},
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# For ai_task and tts subentry types
|
||||
schema.update(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
|
||||
if options.get(CONF_RECOMMENDED):
|
||||
return schema
|
||||
|
||||
api_models_pager = await genai_client.aio.models.list(config={"query_base": True})
|
||||
api_models = [api_model async for api_model in api_models_pager]
|
||||
|
||||
models = [
|
||||
SelectOptionDict(
|
||||
label=api_model.display_name,
|
||||
@@ -342,13 +420,17 @@ async def google_generative_ai_config_option_schema(
|
||||
},
|
||||
default=RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
): harm_block_thresholds_selector,
|
||||
}
|
||||
)
|
||||
if subentry_type == "conversation":
|
||||
schema[
|
||||
vol.Optional(
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
description={
|
||||
"suggested_value": options.get(CONF_USE_GOOGLE_SEARCH_TOOL),
|
||||
},
|
||||
default=RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
)
|
||||
] = bool
|
||||
|
||||
return schema
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.helpers import llm
|
||||
|
||||
DOMAIN = "google_generative_ai_conversation"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
CONF_PROMPT = "prompt"
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Google Conversation"
|
||||
DEFAULT_AI_TASK_NAME = "Google AI Task"
|
||||
|
||||
|
||||
ATTR_MODEL = "model"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
@@ -29,3 +36,13 @@ RECOMMENDED_USE_GOOGLE_SEARCH_TOOL = False
|
||||
|
||||
TIMEOUT_MILLIS = 10000
|
||||
FILE_POLLING_INTERVAL_SECONDS = 0.05
|
||||
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -22,8 +22,14 @@ async def async_setup_entry(
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up conversation entities."""
|
||||
agent = GoogleGenerativeAIConversationEntity(config_entry)
|
||||
async_add_entities([agent])
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "conversation":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[GoogleGenerativeAIConversationEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class GoogleGenerativeAIConversationEntity(
|
||||
@@ -35,10 +41,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
_attr_supports_streaming = True
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry)
|
||||
if self.entry.options.get(CONF_LLM_HASS_API):
|
||||
super().__init__(entry, subentry)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
)
|
||||
@@ -70,7 +76,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Call the API."""
|
||||
options = self.entry.options
|
||||
options = self.subentry.data
|
||||
|
||||
try:
|
||||
await chat_log.async_provide_llm_data(
|
||||
|
||||
@@ -24,7 +24,7 @@ from google.genai.types import (
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
@@ -40,6 +40,7 @@ from .const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
@@ -301,14 +302,13 @@ async def _transform_stream(
|
||||
class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
"""Google Generative AI base entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
def __init__(self, entry: ConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title or DEFAULT_CONVERSATION_NAME
|
||||
self._genai_client = entry.runtime_data
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
name=entry.title,
|
||||
@@ -322,7 +322,7 @@ class GoogleGenerativeAILLMBaseEntity(Entity):
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.entry.options
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[Tool | Callable[..., Any]] | None = None
|
||||
if chat_log.llm_api:
|
||||
|
||||
@@ -21,32 +21,71 @@
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"recommended": "Recommended model settings",
|
||||
"prompt": "Instructions",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"temperature": "Temperature",
|
||||
"top_p": "Top P",
|
||||
"top_k": "Top K",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
|
||||
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
|
||||
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
|
||||
"dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts",
|
||||
"enable_google_search_tool": "Enable Google Search tool"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
|
||||
"config_subentries": {
|
||||
"conversation": {
|
||||
"initiate_flow": {
|
||||
"user": "Add conversation agent",
|
||||
"reconfigure": "Reconfigure conversation agent"
|
||||
},
|
||||
"entry_type": "Conversation agent",
|
||||
|
||||
"step": {
|
||||
"set_options": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "Recommended model settings",
|
||||
"prompt": "Instructions",
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"temperature": "Temperature",
|
||||
"top_p": "Top P",
|
||||
"top_k": "Top K",
|
||||
"max_tokens": "Maximum tokens to return in response",
|
||||
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]",
|
||||
"harassment_block_threshold": "Negative or harmful comments targeting identity and/or protected attributes",
|
||||
"hate_block_threshold": "Content that is rude, disrespectful, or profane",
|
||||
"sexual_block_threshold": "Contains references to sexual acts or other lewd content",
|
||||
"dangerous_block_threshold": "Promotes, facilitates, or encourages harmful acts",
|
||||
"enable_google_search_tool": "Enable Google Search tool"
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template.",
|
||||
"enable_google_search_tool": "Only works if there is nothing selected in the \"Control Home Assistant\" setting. See docs for a workaround using it with \"Assist\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"invalid_google_search_option": "Google Search can only be enabled if nothing is selected in the \"Control Home Assistant\" setting."
|
||||
"ai_task": {
|
||||
"initiate_flow": {
|
||||
"user": "Add AI task service",
|
||||
"reconfigure": "Reconfigure AI task service"
|
||||
},
|
||||
"entry_type": "AI task 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": {
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -22,6 +22,6 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiohomeconnect"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiohomeconnect==0.18.0"],
|
||||
"requirements": ["aiohomeconnect==0.18.1"],
|
||||
"zeroconf": ["_homeconnect._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ class HomeeEntity(Entity):
|
||||
model=get_name_for_enum(NodeProfile, node.profile),
|
||||
via_device=(DOMAIN, entry.runtime_data.settings.uid),
|
||||
)
|
||||
if attribute.name:
|
||||
self._attr_name = attribute.name
|
||||
|
||||
self._host_connected = entry.runtime_data.connected
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["homee"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyHomee==1.2.9"]
|
||||
"requirements": ["pyHomee==1.2.10"]
|
||||
}
|
||||
|
||||
@@ -90,6 +90,24 @@ STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Exclusive(CONF_USERNAME, ATTR_CREDENTIALS): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.TEXT,
|
||||
autocomplete="username",
|
||||
),
|
||||
),
|
||||
vol.Optional(CONF_PASSWORD, default=""): TextSelector(
|
||||
TextSelectorConfig(
|
||||
type=TextSelectorType.PASSWORD,
|
||||
autocomplete="current-password",
|
||||
),
|
||||
),
|
||||
vol.Exclusive(CONF_TOKEN, ATTR_CREDENTIALS): str,
|
||||
}
|
||||
)
|
||||
|
||||
STEP_USER_TOPIC_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TOPIC): str,
|
||||
@@ -244,6 +262,103 @@ class NtfyConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
description_placeholders={CONF_USERNAME: entry.data[CONF_USERNAME]},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow for ntfy."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
session = async_get_clientsession(self.hass)
|
||||
if token := user_input.get(CONF_TOKEN):
|
||||
ntfy = Ntfy(
|
||||
entry.data[CONF_URL],
|
||||
session,
|
||||
token=user_input[CONF_TOKEN],
|
||||
)
|
||||
else:
|
||||
ntfy = Ntfy(
|
||||
entry.data[CONF_URL],
|
||||
session,
|
||||
username=user_input.get(CONF_USERNAME, entry.data[CONF_USERNAME]),
|
||||
password=user_input[CONF_PASSWORD],
|
||||
)
|
||||
|
||||
try:
|
||||
account = await ntfy.account()
|
||||
if not token:
|
||||
token = (await ntfy.generate_token("Home Assistant")).token
|
||||
except NtfyUnauthorizedAuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except NtfyHTTPError as e:
|
||||
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
|
||||
errors["base"] = "cannot_connect"
|
||||
except NtfyException:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
if entry.data[CONF_USERNAME]:
|
||||
if entry.data[CONF_USERNAME] != account.username:
|
||||
return self.async_abort(
|
||||
reason="account_mismatch",
|
||||
description_placeholders={
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
"wrong_username": account.username,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates={CONF_TOKEN: token},
|
||||
)
|
||||
self._async_abort_entries_match(
|
||||
{
|
||||
CONF_URL: entry.data[CONF_URL],
|
||||
CONF_USERNAME: account.username,
|
||||
}
|
||||
)
|
||||
return self.async_update_reload_and_abort(
|
||||
entry,
|
||||
data_updates={
|
||||
CONF_USERNAME: account.username,
|
||||
CONF_TOKEN: token,
|
||||
},
|
||||
)
|
||||
if entry.data[CONF_USERNAME]:
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
suggested_values=user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
CONF_NAME: entry.title,
|
||||
CONF_USERNAME: entry.data[CONF_USERNAME],
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
|
||||
suggested_values=user_input,
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={CONF_NAME: entry.title},
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfigure flow for authenticated ntfy entry."""
|
||||
|
||||
return await self.async_step_reconfigure(user_input)
|
||||
|
||||
|
||||
class TopicSubentryFlowHandler(ConfigSubentryFlow):
|
||||
"""Handle subentry flow for adding and modifying a topic."""
|
||||
|
||||
@@ -72,7 +72,7 @@ rules:
|
||||
comment: the notify entity uses the device name as entity name, no translation required
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: the integration has no repairs
|
||||
|
||||
@@ -39,7 +39,33 @@
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Enter the password corresponding to the aforementioned username to automatically create an access token",
|
||||
"token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and click create access token"
|
||||
"token": "Enter a new access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'"
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
"title": "Configuration for {name}",
|
||||
"description": "You can either log in with your **ntfy** username and password, and Home Assistant will automatically create an access token to authenticate with **ntfy**, or you can provide an access token directly",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::ntfy::config::step::user::sections::auth::data_description::username%]",
|
||||
"password": "[%key:component::ntfy::config::step::user::sections::auth::data_description::password%]",
|
||||
"token": "Enter a new or existing access token. To create a new access token navigate to Account → Access tokens and select 'Create access token'"
|
||||
}
|
||||
},
|
||||
"reconfigure_user": {
|
||||
"title": "[%key:component::ntfy::config::step::reconfigure::title%]",
|
||||
"description": "Enter the password for **{username}** below. Home Assistant will automatically create a new access token to authenticate with **ntfy**. You can also directly provide a valid access token",
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"token": "[%key:common::config_flow::data::access_token%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::ntfy::config::step::reauth_confirm::data_description::password%]",
|
||||
"token": "[%key:component::ntfy::config::step::reconfigure::data_description::token%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -51,7 +77,8 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**"
|
||||
"account_mismatch": "The provided access token corresponds to the account {wrong_username}. Please re-authenticate with the account **{username}**",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiorussound"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiorussound==4.6.0"],
|
||||
"requirements": ["aiorussound==4.6.1"],
|
||||
"zeroconf": ["_rio._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -653,7 +653,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
|
||||
)
|
||||
elif entry is not None:
|
||||
self._attr_unique_id = entry.unique_id
|
||||
self._attr_name = cast(str, entry.original_name)
|
||||
|
||||
@callback
|
||||
def _update_callback(self) -> None:
|
||||
|
||||
@@ -321,8 +321,8 @@ class TelegramNotificationService:
|
||||
for key in row_keyboard.split(","):
|
||||
if ":/" in key:
|
||||
# check if command or URL
|
||||
if key.startswith("https://"):
|
||||
label = key.split(",")[0]
|
||||
if "https://" in key:
|
||||
label = key.split(":")[0]
|
||||
url = key[len(label) + 1 :]
|
||||
buttons.append(InlineKeyboardButton(label, url=url))
|
||||
else:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Support for Traccar Client."""
|
||||
|
||||
from http import HTTPStatus
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
import voluptuous as vol
|
||||
from voluptuous.humanize import humanize_error
|
||||
|
||||
from homeassistant.components import webhook
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -20,7 +23,6 @@ from .const import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
ATTR_SPEED,
|
||||
ATTR_TIMESTAMP,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
@@ -29,6 +31,7 @@ PLATFORMS = [Platform.DEVICE_TRACKER]
|
||||
|
||||
TRACKER_UPDATE = f"{DOMAIN}_tracker_update"
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_ACCURACY = 200
|
||||
DEFAULT_BATTERY = -1
|
||||
@@ -49,21 +52,50 @@ WEBHOOK_SCHEMA = vol.Schema(
|
||||
vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float),
|
||||
vol.Optional(ATTR_BEARING): vol.Coerce(float),
|
||||
vol.Optional(ATTR_SPEED): vol.Coerce(float),
|
||||
vol.Optional(ATTR_TIMESTAMP): vol.Coerce(int),
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def _parse_json_body(json_body: dict) -> dict:
|
||||
"""Parse JSON body from request."""
|
||||
location = json_body.get("location", {})
|
||||
coords = location.get("coords", {})
|
||||
battery_level = location.get("battery", {}).get("level")
|
||||
return {
|
||||
"id": json_body.get("device_id"),
|
||||
"lat": coords.get("latitude"),
|
||||
"lon": coords.get("longitude"),
|
||||
"accuracy": coords.get("accuracy"),
|
||||
"altitude": coords.get("altitude"),
|
||||
"batt": battery_level * 100 if battery_level is not None else DEFAULT_BATTERY,
|
||||
"bearing": coords.get("heading"),
|
||||
"speed": coords.get("speed"),
|
||||
}
|
||||
|
||||
|
||||
async def handle_webhook(
|
||||
hass: HomeAssistant, webhook_id: str, request: web.Request
|
||||
hass: HomeAssistant,
|
||||
webhook_id: str,
|
||||
request: web.Request,
|
||||
) -> web.Response:
|
||||
"""Handle incoming webhook with Traccar Client request."""
|
||||
if not (requestdata := dict(request.query)):
|
||||
try:
|
||||
requestdata = _parse_json_body(await request.json())
|
||||
except JSONDecodeError as error:
|
||||
LOGGER.error("Error parsing JSON body: %s", error)
|
||||
return web.Response(
|
||||
text="Invalid JSON",
|
||||
status=HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
try:
|
||||
data = WEBHOOK_SCHEMA(dict(request.query))
|
||||
data = WEBHOOK_SCHEMA(requestdata)
|
||||
except vol.MultipleInvalid as error:
|
||||
LOGGER.warning(humanize_error(requestdata, error))
|
||||
return web.Response(
|
||||
text=error.error_message, status=HTTPStatus.UNPROCESSABLE_ENTITY
|
||||
text=error.error_message,
|
||||
status=HTTPStatus.UNPROCESSABLE_ENTITY,
|
||||
)
|
||||
|
||||
attrs = {
|
||||
|
||||
@@ -17,7 +17,6 @@ ATTR_LONGITUDE = "lon"
|
||||
ATTR_MOTION = "motion"
|
||||
ATTR_SPEED = "speed"
|
||||
ATTR_STATUS = "status"
|
||||
ATTR_TIMESTAMP = "timestamp"
|
||||
ATTR_TRACKER = "tracker"
|
||||
ATTR_TRACCAR_ID = "traccar_id"
|
||||
|
||||
|
||||
@@ -89,11 +89,11 @@ class TextToSpeechEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH
|
||||
"""Return a mapping with the default options."""
|
||||
return self._attr_default_options
|
||||
|
||||
@classmethod
|
||||
def async_supports_streaming_input(cls) -> bool:
|
||||
def async_supports_streaming_input(self) -> bool:
|
||||
"""Return if the TTS engine supports streaming input."""
|
||||
return (
|
||||
cls.async_stream_tts_audio is not TextToSpeechEntity.async_stream_tts_audio
|
||||
self.__class__.async_stream_tts_audio
|
||||
is not TextToSpeechEntity.async_stream_tts_audio
|
||||
)
|
||||
|
||||
@callback
|
||||
|
||||
@@ -3420,6 +3420,11 @@ class ConfigSubentryFlow(
|
||||
"""Return config entry id."""
|
||||
return self.handler[0]
|
||||
|
||||
@property
|
||||
def _subentry_type(self) -> str:
|
||||
"""Return type of subentry we are editing/creating."""
|
||||
return self.handler[1]
|
||||
|
||||
@callback
|
||||
def _get_entry(self) -> ConfigEntry:
|
||||
"""Return the config entry linked to the current context."""
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -182,7 +182,7 @@ aioairzone-cloud==0.6.12
|
||||
aioairzone==1.0.0
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==3.1.12
|
||||
aioamazondevices==3.1.14
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -265,7 +265,7 @@ aioharmony==0.5.2
|
||||
aiohasupervisor==0.3.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.18.0
|
||||
aiohomeconnect==0.18.1
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.15
|
||||
@@ -369,7 +369,7 @@ aioridwell==2024.01.0
|
||||
aioruckus==0.42
|
||||
|
||||
# homeassistant.components.russound_rio
|
||||
aiorussound==4.6.0
|
||||
aiorussound==4.6.1
|
||||
|
||||
# homeassistant.components.ruuvi_gateway
|
||||
aioruuvigateway==0.1.0
|
||||
@@ -765,7 +765,7 @@ decora-wifi==1.4
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==13.3.0
|
||||
deebot-client==13.4.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -1799,7 +1799,7 @@ pyEmby==1.10
|
||||
pyHik==0.3.2
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.2.9
|
||||
pyHomee==1.2.10
|
||||
|
||||
# homeassistant.components.rfxtrx
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -170,7 +170,7 @@ aioairzone-cloud==0.6.12
|
||||
aioairzone==1.0.0
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==3.1.12
|
||||
aioamazondevices==3.1.14
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -250,7 +250,7 @@ aioharmony==0.5.2
|
||||
aiohasupervisor==0.3.1
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.18.0
|
||||
aiohomeconnect==0.18.1
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.15
|
||||
@@ -351,7 +351,7 @@ aioridwell==2024.01.0
|
||||
aioruckus==0.42
|
||||
|
||||
# homeassistant.components.russound_rio
|
||||
aiorussound==4.6.0
|
||||
aiorussound==4.6.1
|
||||
|
||||
# homeassistant.components.ruuvi_gateway
|
||||
aioruuvigateway==0.1.0
|
||||
@@ -665,7 +665,7 @@ debugpy==1.8.14
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==13.3.0
|
||||
deebot-client==13.4.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -1510,7 +1510,7 @@ pyDuotecno==2024.10.1
|
||||
pyElectra==1.2.4
|
||||
|
||||
# homeassistant.components.homee
|
||||
pyHomee==1.2.9
|
||||
pyHomee==1.2.10
|
||||
|
||||
# homeassistant.components.rfxtrx
|
||||
pyRFXtrx==0.31.1
|
||||
|
||||
@@ -243,12 +243,12 @@ async def test_pipeline_api_audio(
|
||||
event_callback(
|
||||
PipelineEvent(
|
||||
type=PipelineEventType.INTENT_PROGRESS,
|
||||
data={"tts_start_streaming": True},
|
||||
data={"tts_start_streaming": "1"},
|
||||
)
|
||||
)
|
||||
assert mock_client.send_voice_assistant_event.call_args_list[-1].args == (
|
||||
VoiceAssistantEventType.VOICE_ASSISTANT_INTENT_PROGRESS,
|
||||
{"tts_start_streaming": True},
|
||||
{"tts_start_streaming": "1"},
|
||||
)
|
||||
|
||||
event_callback(
|
||||
|
||||
@@ -5,8 +5,10 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.google_generative_ai_conversation.entity import (
|
||||
from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
@@ -26,6 +28,21 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
data={
|
||||
"api_key": "bla",
|
||||
},
|
||||
version=2,
|
||||
subentries_data=[
|
||||
{
|
||||
"data": {},
|
||||
"subentry_type": "conversation",
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"data": {},
|
||||
"subentry_type": "ai_task",
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
entry.runtime_data = Mock()
|
||||
entry.add_to_hass(hass)
|
||||
@@ -38,8 +55,10 @@ async def mock_config_entry_with_assist(
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry with assist."""
|
||||
with patch("google.genai.models.AsyncModels.get"):
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry, options={CONF_LLM_HASS_API: llm.LLM_API_ASSIST}
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={CONF_LLM_HASS_API: llm.LLM_API_ASSIST},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
return mock_config_entry
|
||||
@@ -51,9 +70,10 @@ async def mock_config_entry_with_google_search(
|
||||
) -> MockConfigEntry:
|
||||
"""Mock a config entry with assist."""
|
||||
with patch("google.genai.models.AsyncModels.get"):
|
||||
hass.config_entries.async_update_entry(
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
options={
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={
|
||||
CONF_LLM_HASS_API: llm.LLM_API_ASSIST,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL: True,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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 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,
|
||||
) -> None:
|
||||
"""Test empty response."""
|
||||
entity_id = "ai_task.google_ai_task"
|
||||
mock_send_message_stream.return_value = [
|
||||
[
|
||||
GenerateContentResponse(
|
||||
candidates=[
|
||||
{
|
||||
"content": {
|
||||
"parts": [{"text": "Hi there!"}],
|
||||
"role": "model",
|
||||
},
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
]
|
||||
result = await ai_task.async_generate_text(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Test prompt",
|
||||
)
|
||||
assert result.text == "Hi there!"
|
||||
@@ -6,9 +6,6 @@ import pytest
|
||||
from requests.exceptions import Timeout
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.google_generative_ai_conversation.config_flow import (
|
||||
RECOMMENDED_OPTIONS,
|
||||
)
|
||||
from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD,
|
||||
@@ -22,15 +19,19 @@ from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
CONF_TOP_K,
|
||||
CONF_TOP_P,
|
||||
CONF_USE_GOOGLE_SEARCH_TOOL,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TOP_K,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_USE_GOOGLE_SEARCH_TOOL,
|
||||
)
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
from homeassistant.const import CONF_LLM_HASS_API, CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
@@ -110,10 +111,110 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
assert result2["data"] == {
|
||||
"api_key": "bla",
|
||||
}
|
||||
assert result2["options"] == RECOMMENDED_OPTIONS
|
||||
assert result2["options"] == {}
|
||||
assert result2["subentries"] == [
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_creating_conversation_subentry(
|
||||
hass: HomeAssistant,
|
||||
mock_init_component: None,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating a conversation subentry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
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, "conversation"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "set_options"
|
||||
assert not result["errors"]
|
||||
|
||||
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 name", **RECOMMENDED_CONVERSATION_OPTIONS},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "Mock name"
|
||||
|
||||
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
||||
|
||||
assert result2["data"] == processed_options
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
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"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
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) == 3
|
||||
|
||||
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"
|
||||
assert new_subentry.data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
assert new_subentry.title == "Mock AI Task"
|
||||
|
||||
|
||||
def will_options_be_rendered_again(current_options, new_options) -> bool:
|
||||
"""Determine if options will be rendered again."""
|
||||
return current_options.get(CONF_RECOMMENDED) != new_options.get(CONF_RECOMMENDED)
|
||||
@@ -283,7 +384,7 @@ def will_options_be_rendered_again(current_options, new_options) -> bool:
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_init_component")
|
||||
async def test_options_switching(
|
||||
async def test_subentry_options_switching(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
current_options,
|
||||
@@ -292,17 +393,18 @@ async def test_options_switching(
|
||||
errors,
|
||||
) -> None:
|
||||
"""Test the options form."""
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
with patch("google.genai.models.AsyncModels.get"):
|
||||
hass.config_entries.async_update_entry(
|
||||
mock_config_entry, options=current_options
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry, subentry, data=current_options
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
with patch(
|
||||
"google.genai.models.AsyncModels.list",
|
||||
return_value=get_models_pager(),
|
||||
):
|
||||
options_flow = await hass.config_entries.options.async_init(
|
||||
mock_config_entry.entry_id
|
||||
options_flow = await mock_config_entry.start_subentry_reconfigure_flow(
|
||||
hass, subentry.subentry_type, subentry.subentry_id
|
||||
)
|
||||
if will_options_be_rendered_again(current_options, new_options):
|
||||
retry_options = {
|
||||
@@ -313,7 +415,7 @@ async def test_options_switching(
|
||||
"google.genai.models.AsyncModels.list",
|
||||
return_value=get_models_pager(),
|
||||
):
|
||||
options_flow = await hass.config_entries.options.async_configure(
|
||||
options_flow = await hass.config_entries.subentries.async_configure(
|
||||
options_flow["flow_id"],
|
||||
retry_options,
|
||||
)
|
||||
@@ -321,14 +423,15 @@ async def test_options_switching(
|
||||
"google.genai.models.AsyncModels.list",
|
||||
return_value=get_models_pager(),
|
||||
):
|
||||
options = await hass.config_entries.options.async_configure(
|
||||
options = await hass.config_entries.subentries.async_configure(
|
||||
options_flow["flow_id"],
|
||||
new_options,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
if errors is None:
|
||||
assert options["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert options["data"] == expected_options
|
||||
assert options["type"] is FlowResultType.ABORT
|
||||
assert options["reason"] == "reconfigure_successful"
|
||||
assert subentry.data == expected_options
|
||||
|
||||
else:
|
||||
assert options["type"] is FlowResultType.FORM
|
||||
|
||||
@@ -64,7 +64,7 @@ async def test_error_handling(
|
||||
"hello",
|
||||
None,
|
||||
Context(),
|
||||
agent_id="conversation.google_generative_ai_conversation",
|
||||
agent_id="conversation.google_conversation",
|
||||
)
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR, result
|
||||
assert result.response.error_code == "unknown", result
|
||||
@@ -82,7 +82,7 @@ async def test_function_call(
|
||||
mock_send_message_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test function calling."""
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -212,7 +212,7 @@ async def test_google_search_tool_is_sent(
|
||||
mock_send_message_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test if the Google Search tool is sent to the model."""
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -278,7 +278,7 @@ async def test_blocked_response(
|
||||
mock_send_message_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test blocked response."""
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -328,7 +328,7 @@ async def test_empty_response(
|
||||
) -> None:
|
||||
"""Test empty response."""
|
||||
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -371,7 +371,7 @@ async def test_none_response(
|
||||
mock_send_message_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test None response."""
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -403,10 +403,12 @@ async def test_converse_error(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test handling ChatLog raising ConverseError."""
|
||||
subentry = next(iter(mock_config_entry.subentries.values()))
|
||||
with patch("google.genai.models.AsyncModels.get"):
|
||||
hass.config_entries.async_update_entry(
|
||||
hass.config_entries.async_update_subentry(
|
||||
mock_config_entry,
|
||||
options={**mock_config_entry.options, CONF_LLM_HASS_API: "invalid_llm_api"},
|
||||
next(iter(mock_config_entry.subentries.values())),
|
||||
data={**subentry.data, CONF_LLM_HASS_API: "invalid_llm_api"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -415,7 +417,7 @@ async def test_converse_error(
|
||||
"hello",
|
||||
None,
|
||||
Context(),
|
||||
agent_id="conversation.google_generative_ai_conversation",
|
||||
agent_id="conversation.google_conversation",
|
||||
)
|
||||
|
||||
assert result.response.response_type == intent.IntentResponseType.ERROR, result
|
||||
@@ -593,7 +595,7 @@ async def test_empty_content_in_chat_history(
|
||||
mock_send_message_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Tests that in case of an empty entry in the chat history the google API will receive an injected space sign instead."""
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -648,7 +650,7 @@ async def test_history_always_user_first_turn(
|
||||
) -> None:
|
||||
"""Test that the user is always first in the chat history."""
|
||||
|
||||
agent_id = "conversation.google_generative_ai_conversation"
|
||||
agent_id = "conversation.google_conversation"
|
||||
context = Context()
|
||||
|
||||
messages = [
|
||||
@@ -674,7 +676,7 @@ async def test_history_always_user_first_turn(
|
||||
|
||||
mock_chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id="conversation.google_generative_ai_conversation",
|
||||
agent_id="conversation.google_conversation",
|
||||
content="Garage door left open, do you want to close it?",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -7,9 +7,19 @@ import pytest
|
||||
from requests.exceptions import Timeout
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.google_generative_ai_conversation import (
|
||||
async_migrate_entry,
|
||||
)
|
||||
from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from . import API_ERROR_500, CLIENT_ERROR_API_KEY_INVALID
|
||||
|
||||
@@ -387,3 +397,76 @@ async def test_load_entry_with_unloaded_entries(
|
||||
"text": stubbed_generated_content,
|
||||
}
|
||||
assert [tuple(mock_call) for mock_call in mock_generate.mock_calls] == snapshot
|
||||
|
||||
|
||||
async def test_migration_from_v1_to_v2(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migration from version 1 to version 2."""
|
||||
# Create a v1 config entry with conversation options and an entity
|
||||
OPTIONS = {
|
||||
"recommended": True,
|
||||
"llm_hass_api": ["assist"],
|
||||
"prompt": "You are a helpful assistant",
|
||||
"chat_model": "models/gemini-2.0-flash",
|
||||
}
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={"api_key": "1234"},
|
||||
options=OPTIONS,
|
||||
version=1,
|
||||
title="Google Generative AI",
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)},
|
||||
name=mock_config_entry.title,
|
||||
manufacturer="Google",
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
entity = entity_registry.async_get_or_create(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
"mock_config_entry.entry_id",
|
||||
config_entry=mock_config_entry,
|
||||
device_id=device.id,
|
||||
suggested_object_id="google_generative_ai_conversation",
|
||||
)
|
||||
|
||||
# Run migration
|
||||
result = await async_migrate_entry(hass, mock_config_entry)
|
||||
|
||||
assert result is True
|
||||
assert mock_config_entry.version == 2
|
||||
assert mock_config_entry.data == {"api_key": "1234"}
|
||||
assert mock_config_entry.options == {}
|
||||
|
||||
assert len(mock_config_entry.subentries) == 2
|
||||
|
||||
subentries = {
|
||||
subentry.subentry_type: subentry
|
||||
for subentry in mock_config_entry.subentries.values()
|
||||
}
|
||||
|
||||
conversation_subentry = subentries["conversation"]
|
||||
assert conversation_subentry.unique_id is None
|
||||
assert conversation_subentry.title == DEFAULT_CONVERSATION_NAME
|
||||
assert conversation_subentry.subentry_type == "conversation"
|
||||
assert conversation_subentry.data == OPTIONS
|
||||
|
||||
migrated_entity = entity_registry.async_get(entity.entity_id)
|
||||
assert migrated_entity is not None
|
||||
assert migrated_entity.config_entry_id == mock_config_entry.entry_id
|
||||
assert migrated_entity.config_subentry_id == conversation_subentry.subentry_id
|
||||
assert migrated_entity.device_id == device.id
|
||||
|
||||
ai_task_subentry = subentries["ai_task"]
|
||||
assert ai_task_subentry.unique_id is None
|
||||
assert ai_task_subentry.title == DEFAULT_AI_TASK_NAME
|
||||
assert ai_task_subentry.subentry_type == "ai_task"
|
||||
assert ai_task_subentry.data == RECOMMENDED_AI_TASK_OPTIONS
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
"changed_by_id": 0,
|
||||
"based_on": 1,
|
||||
"data": "",
|
||||
"name": ""
|
||||
"name": "Kitchen Light"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_event_snapshot[event.remote_control_switch_1-entry]
|
||||
# name: test_event_snapshot[event.remote_control_kitchen_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -18,7 +18,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'event',
|
||||
'entity_category': None,
|
||||
'entity_id': 'event.remote_control_switch_1',
|
||||
'entity_id': 'event.remote_control_kitchen_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -30,7 +30,7 @@
|
||||
}),
|
||||
'original_device_class': <EventDeviceClass.BUTTON: 'button'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Switch 1',
|
||||
'original_name': 'Kitchen Light',
|
||||
'platform': 'homee',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -40,7 +40,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_event_snapshot[event.remote_control_switch_1-state]
|
||||
# name: test_event_snapshot[event.remote_control_kitchen_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
@@ -50,10 +50,10 @@
|
||||
'lower',
|
||||
'released',
|
||||
]),
|
||||
'friendly_name': 'Remote Control Switch 1',
|
||||
'friendly_name': 'Remote Control Kitchen Light',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'event.remote_control_switch_1',
|
||||
'entity_id': 'event.remote_control_kitchen_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
@@ -498,3 +498,236 @@ async def test_flow_reauth_account_mismatch(
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "account_mismatch"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entry_data", "user_input", "step_id"),
|
||||
[
|
||||
(
|
||||
{CONF_USERNAME: None, CONF_TOKEN: None},
|
||||
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
|
||||
"reconfigure",
|
||||
),
|
||||
(
|
||||
{CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"},
|
||||
{CONF_TOKEN: "newtoken"},
|
||||
"reconfigure_user",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_flow_reconfigure(
|
||||
hass: HomeAssistant,
|
||||
mock_aiontfy: AsyncMock,
|
||||
entry_data: dict[str, str | None],
|
||||
user_input: dict[str, str],
|
||||
step_id: str,
|
||||
) -> None:
|
||||
"""Test reconfigure flow."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="ntfy.sh",
|
||||
data={
|
||||
CONF_URL: "https://ntfy.sh/",
|
||||
**entry_data,
|
||||
},
|
||||
)
|
||||
mock_aiontfy.generate_token.return_value = AccountTokenResponse(
|
||||
token="newtoken", last_access=datetime.now()
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == step_id
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input,
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert config_entry.data[CONF_USERNAME] == "username"
|
||||
assert config_entry.data[CONF_TOKEN] == "newtoken"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entry_data", "step_id"),
|
||||
[
|
||||
({CONF_USERNAME: None, CONF_TOKEN: None}, "reconfigure"),
|
||||
({CONF_USERNAME: "username", CONF_TOKEN: "oldtoken"}, "reconfigure_user"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_aiontfy")
|
||||
async def test_flow_reconfigure_token(
|
||||
hass: HomeAssistant,
|
||||
entry_data: dict[str, Any],
|
||||
step_id: str,
|
||||
) -> None:
|
||||
"""Test reconfigure flow with access token."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="ntfy.sh",
|
||||
data={
|
||||
CONF_URL: "https://ntfy.sh/",
|
||||
**entry_data,
|
||||
},
|
||||
)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == step_id
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_TOKEN: "access_token"},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert config_entry.data[CONF_USERNAME] == "username"
|
||||
assert config_entry.data[CONF_TOKEN] == "access_token"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "error"),
|
||||
[
|
||||
(
|
||||
NtfyHTTPError(418001, 418, "I'm a teapot", ""),
|
||||
"cannot_connect",
|
||||
),
|
||||
(
|
||||
NtfyUnauthorizedAuthenticationError(
|
||||
40101,
|
||||
401,
|
||||
"unauthorized",
|
||||
"https://ntfy.sh/docs/publish/#authentication",
|
||||
),
|
||||
"invalid_auth",
|
||||
),
|
||||
(NtfyException, "cannot_connect"),
|
||||
(TypeError, "unknown"),
|
||||
],
|
||||
)
|
||||
async def test_flow_reconfigure_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_aiontfy: AsyncMock,
|
||||
exception: Exception,
|
||||
error: str,
|
||||
) -> None:
|
||||
"""Test reconfigure flow errors."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="ntfy.sh",
|
||||
data={
|
||||
CONF_URL: "https://ntfy.sh/",
|
||||
CONF_USERNAME: None,
|
||||
CONF_TOKEN: None,
|
||||
},
|
||||
)
|
||||
mock_aiontfy.generate_token.return_value = AccountTokenResponse(
|
||||
token="newtoken", last_access=datetime.now()
|
||||
)
|
||||
mock_aiontfy.account.side_effect = exception
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": error}
|
||||
|
||||
mock_aiontfy.account.side_effect = None
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
|
||||
)
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "reconfigure_successful"
|
||||
assert config_entry.data[CONF_USERNAME] == "username"
|
||||
assert config_entry.data[CONF_TOKEN] == "newtoken"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_aiontfy")
|
||||
async def test_flow_reconfigure_already_configured(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test reconfigure flow already configured."""
|
||||
other_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="ntfy.sh",
|
||||
data={
|
||||
CONF_URL: "https://ntfy.sh/",
|
||||
CONF_USERNAME: "username",
|
||||
},
|
||||
)
|
||||
other_config_entry.add_to_hass(hass)
|
||||
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: "username", CONF_PASSWORD: "password"},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
assert len(hass.config_entries.async_entries()) == 2
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_aiontfy")
|
||||
async def test_flow_reconfigure_account_mismatch(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test reconfigure flow account mismatch."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="ntfy.sh",
|
||||
data={
|
||||
CONF_URL: "https://ntfy.sh/",
|
||||
CONF_USERNAME: "wrong_username",
|
||||
CONF_TOKEN: "oldtoken",
|
||||
},
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
result = await config_entry.start_reconfigure_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reconfigure_user"
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_TOKEN: "newtoken"},
|
||||
)
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "account_mismatch"
|
||||
|
||||
@@ -23,6 +23,7 @@ from homeassistant.components.shelly.const import DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_FRIENDLY_NAME,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
PERCENTAGE,
|
||||
STATE_UNAVAILABLE,
|
||||
@@ -40,6 +41,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from . import (
|
||||
MOCK_MAC,
|
||||
init_integration,
|
||||
mock_polling_rpc_update,
|
||||
mock_rest_update,
|
||||
@@ -1585,3 +1587,45 @@ async def test_rpc_switch_no_returned_energy_sensor(
|
||||
await init_integration(hass, 3)
|
||||
|
||||
assert hass.states.get("sensor.test_name_test_switch_0_returned_energy") is None
|
||||
|
||||
|
||||
async def test_block_friendly_name_sleeping_sensor(
|
||||
hass: HomeAssistant,
|
||||
mock_block_device: Mock,
|
||||
device_registry: DeviceRegistry,
|
||||
entity_registry: EntityRegistry,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Test friendly name for restored sleeping sensor."""
|
||||
entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True)
|
||||
device = register_device(device_registry, entry)
|
||||
|
||||
entity = entity_registry.async_get_or_create(
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
f"{MOCK_MAC}-sensor_0-temp",
|
||||
suggested_object_id="test_name_temperature",
|
||||
original_name="Test name temperature",
|
||||
disabled_by=None,
|
||||
config_entry=entry,
|
||||
device_id=device.id,
|
||||
)
|
||||
|
||||
# Old name, the word "temperature" starts with a lower case letter
|
||||
assert entity.original_name == "Test name temperature"
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (state := hass.states.get(entity.entity_id))
|
||||
|
||||
# New name, the word "temperature" starts with a capital letter
|
||||
assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature"
|
||||
|
||||
# Make device online
|
||||
monkeypatch.setattr(mock_block_device, "initialized", True)
|
||||
mock_block_device.mock_online()
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
assert (state := hass.states.get(entity.entity_id))
|
||||
assert state.attributes[ATTR_FRIENDLY_NAME] == "Test name Temperature"
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Tests for the telegram_bot component."""
|
||||
|
||||
import base64
|
||||
from datetime import datetime
|
||||
import io
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||||
|
||||
import pytest
|
||||
from telegram import Update
|
||||
from telegram import Chat, InlineKeyboardButton, InlineKeyboardMarkup, Message, Update
|
||||
from telegram.constants import ChatType, ParseMode
|
||||
from telegram.error import (
|
||||
InvalidToken,
|
||||
NetworkError,
|
||||
@@ -16,28 +18,37 @@ from telegram.error import (
|
||||
)
|
||||
|
||||
from homeassistant.components.telegram_bot import (
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
async_setup_entry,
|
||||
)
|
||||
from homeassistant.components.telegram_bot.const import (
|
||||
ATTR_AUTHENTICATION,
|
||||
ATTR_CALLBACK_QUERY_ID,
|
||||
ATTR_CAPTION,
|
||||
ATTR_CHAT_ID,
|
||||
ATTR_DISABLE_NOTIF,
|
||||
ATTR_DISABLE_WEB_PREV,
|
||||
ATTR_FILE,
|
||||
ATTR_KEYBOARD,
|
||||
ATTR_KEYBOARD_INLINE,
|
||||
ATTR_LATITUDE,
|
||||
ATTR_LONGITUDE,
|
||||
ATTR_MESSAGE,
|
||||
ATTR_MESSAGE_TAG,
|
||||
ATTR_MESSAGE_THREAD_ID,
|
||||
ATTR_MESSAGEID,
|
||||
ATTR_OPTIONS,
|
||||
ATTR_PARSER,
|
||||
ATTR_PASSWORD,
|
||||
ATTR_QUESTION,
|
||||
ATTR_REPLY_TO_MSGID,
|
||||
ATTR_SHOW_ALERT,
|
||||
ATTR_STICKER_ID,
|
||||
ATTR_TARGET,
|
||||
ATTR_TIMEOUT,
|
||||
ATTR_URL,
|
||||
ATTR_USERNAME,
|
||||
ATTR_VERIFY_SSL,
|
||||
CONF_CONFIG_ENTRY_ID,
|
||||
CONF_PLATFORM,
|
||||
DOMAIN,
|
||||
PLATFORM_BROADCAST,
|
||||
SERVICE_ANSWER_CALLBACK_QUERY,
|
||||
@@ -55,12 +66,12 @@ from homeassistant.components.telegram_bot import (
|
||||
SERVICE_SEND_STICKER,
|
||||
SERVICE_SEND_VIDEO,
|
||||
SERVICE_SEND_VOICE,
|
||||
async_setup_entry,
|
||||
)
|
||||
from homeassistant.components.telegram_bot.webhooks import TELEGRAM_WEBHOOK_URL
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
CONF_PLATFORM,
|
||||
HTTP_BASIC_AUTHENTICATION,
|
||||
HTTP_BEARER_AUTHENTICATION,
|
||||
HTTP_DIGEST_AUTHENTICATION,
|
||||
@@ -96,6 +107,26 @@ async def test_polling_platform_init(hass: HomeAssistant, polling_platform) -> N
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{ATTR_MESSAGE: "test_message", ATTR_MESSAGE_THREAD_ID: "123"},
|
||||
),
|
||||
(
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_KEYBOARD: ["/command1, /command2", "/command3"],
|
||||
ATTR_MESSAGE: "test_message",
|
||||
ATTR_PARSER: ParseMode.HTML,
|
||||
ATTR_TIMEOUT: 15,
|
||||
ATTR_DISABLE_NOTIF: True,
|
||||
ATTR_DISABLE_WEB_PREV: True,
|
||||
ATTR_MESSAGE_TAG: "mock_tag",
|
||||
ATTR_REPLY_TO_MSGID: 12345,
|
||||
},
|
||||
),
|
||||
(
|
||||
SERVICE_SEND_MESSAGE,
|
||||
{
|
||||
ATTR_KEYBOARD: [],
|
||||
ATTR_MESSAGE: "test_message",
|
||||
},
|
||||
),
|
||||
(
|
||||
SERVICE_SEND_STICKER,
|
||||
{
|
||||
@@ -145,6 +176,95 @@ async def test_send_message(
|
||||
assert (response["chats"][0]["message_id"]) == 12345
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("input", "expected"),
|
||||
[
|
||||
(
|
||||
{
|
||||
ATTR_MESSAGE: "test_message",
|
||||
ATTR_KEYBOARD_INLINE: "command1:/cmd1,/cmd2,mock_link:https://mock_link",
|
||||
},
|
||||
InlineKeyboardMarkup(
|
||||
# 1 row with 3 buttons
|
||||
[
|
||||
[
|
||||
InlineKeyboardButton(callback_data="/cmd1", text="command1"),
|
||||
InlineKeyboardButton(callback_data="/cmd2", text="CMD2"),
|
||||
InlineKeyboardButton(url="https://mock_link", text="mock_link"),
|
||||
]
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
{
|
||||
ATTR_MESSAGE: "test_message",
|
||||
ATTR_KEYBOARD_INLINE: [
|
||||
[["command1", "/cmd1"]],
|
||||
[["mock_link", "https://mock_link"]],
|
||||
],
|
||||
},
|
||||
InlineKeyboardMarkup(
|
||||
# 2 rows each with 1 button
|
||||
[
|
||||
[InlineKeyboardButton(callback_data="/cmd1", text="command1")],
|
||||
[InlineKeyboardButton(url="https://mock_link", text="mock_link")],
|
||||
]
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_send_message_with_inline_keyboard(
|
||||
hass: HomeAssistant,
|
||||
webhook_platform,
|
||||
input: dict[str, Any],
|
||||
expected: InlineKeyboardMarkup,
|
||||
) -> None:
|
||||
"""Test the send_message service.
|
||||
|
||||
Tests any service that does not require files to be sent.
|
||||
"""
|
||||
context = Context()
|
||||
events = async_capture_events(hass, "telegram_sent")
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.telegram_bot.bot.Bot.send_message",
|
||||
AsyncMock(
|
||||
return_value=Message(
|
||||
message_id=12345,
|
||||
date=datetime.now(),
|
||||
chat=Chat(id=123456, type=ChatType.PRIVATE),
|
||||
)
|
||||
),
|
||||
) as mock_send_message:
|
||||
response = await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SEND_MESSAGE,
|
||||
input,
|
||||
blocking=True,
|
||||
context=context,
|
||||
return_response=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_send_message.assert_called_once_with(
|
||||
12345678,
|
||||
"test_message",
|
||||
parse_mode=ParseMode.MARKDOWN,
|
||||
disable_web_page_preview=None,
|
||||
disable_notification=False,
|
||||
reply_to_message_id=None,
|
||||
reply_markup=expected,
|
||||
read_timeout=None,
|
||||
message_thread_id=None,
|
||||
)
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0].context == context
|
||||
|
||||
assert len(response["chats"]) == 1
|
||||
assert (response["chats"][0]["message_id"]) == 12345
|
||||
|
||||
|
||||
@patch(
|
||||
"builtins.open",
|
||||
mock_open(
|
||||
|
||||
@@ -146,8 +146,12 @@ async def test_enter_and_exit(
|
||||
assert len(entity_registry.entities) == 1
|
||||
|
||||
|
||||
async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None:
|
||||
"""Test when additional attributes are present."""
|
||||
async def test_enter_with_attrs_as_query(
|
||||
hass: HomeAssistant,
|
||||
client,
|
||||
webhook_id,
|
||||
) -> None:
|
||||
"""Test when additional attributes are present URL query."""
|
||||
url = f"/api/webhook/{webhook_id}"
|
||||
data = {
|
||||
"timestamp": 123456789,
|
||||
@@ -197,6 +201,45 @@ async def test_enter_with_attrs(hass: HomeAssistant, client, webhook_id) -> None
|
||||
assert state.attributes["altitude"] == 123
|
||||
|
||||
|
||||
async def test_enter_with_attrs_as_payload(
|
||||
hass: HomeAssistant, client, webhook_id
|
||||
) -> None:
|
||||
"""Test when additional attributes are present in JSON payload."""
|
||||
url = f"/api/webhook/{webhook_id}"
|
||||
data = {
|
||||
"location": {
|
||||
"coords": {
|
||||
"heading": "105.32",
|
||||
"latitude": "1.0",
|
||||
"longitude": "1.1",
|
||||
"accuracy": 10.5,
|
||||
"altitude": 102.0,
|
||||
"speed": 100.0,
|
||||
},
|
||||
"extras": {},
|
||||
"manual": True,
|
||||
"is_moving": False,
|
||||
"_": "&id=123&lat=1.0&lon=1.1×tamp=2013-09-17T07:32:51Z&",
|
||||
"odometer": 0,
|
||||
"activity": {"type": "still"},
|
||||
"timestamp": "2013-09-17T07:32:51Z",
|
||||
"battery": {"level": 0.1, "is_charging": False},
|
||||
},
|
||||
"device_id": "123",
|
||||
}
|
||||
|
||||
req = await client.post(url, json=data)
|
||||
await hass.async_block_till_done()
|
||||
assert req.status == HTTPStatus.OK
|
||||
state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device_id']}")
|
||||
assert state.state == STATE_NOT_HOME
|
||||
assert state.attributes["gps_accuracy"] == 10.5
|
||||
assert state.attributes["battery_level"] == 10.0
|
||||
assert state.attributes["speed"] == 100.0
|
||||
assert state.attributes["bearing"] == 105.32
|
||||
assert state.attributes["altitude"] == 102.0
|
||||
|
||||
|
||||
async def test_two_devices(hass: HomeAssistant, client, webhook_id) -> None:
|
||||
"""Test updating two different devices."""
|
||||
url = f"/api/webhook/{webhook_id}"
|
||||
|
||||
Reference in New Issue
Block a user