diff --git a/homeassistant/components/anthropic/conversation.py b/homeassistant/components/anthropic/conversation.py index f34d9ed97b67e2..531d007cf52c24 100644 --- a/homeassistant/components/anthropic/conversation.py +++ b/homeassistant/components/anthropic/conversation.py @@ -1,69 +1,17 @@ """Conversation support for Anthropic.""" -from collections.abc import AsyncGenerator, Callable, Iterable -import json -from typing import Any, Literal, cast - -import anthropic -from anthropic import AsyncStream -from anthropic._types import NOT_GIVEN -from anthropic.types import ( - InputJSONDelta, - MessageDeltaUsage, - MessageParam, - MessageStreamEvent, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawContentBlockStopEvent, - RawMessageDeltaEvent, - RawMessageStartEvent, - RawMessageStopEvent, - RedactedThinkingBlock, - RedactedThinkingBlockParam, - SignatureDelta, - TextBlock, - TextBlockParam, - TextDelta, - ThinkingBlock, - ThinkingBlockParam, - ThinkingConfigDisabledParam, - ThinkingConfigEnabledParam, - ThinkingDelta, - ToolParam, - ToolResultBlockParam, - ToolUseBlock, - ToolUseBlockParam, - Usage, -) -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import conversation from homeassistant.config_entries import ConfigEntry, ConfigSubentry from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import AnthropicConfigEntry -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_TEMPERATURE, - CONF_THINKING_BUDGET, - DOMAIN, - LOGGER, - MIN_THINKING_BUDGET, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_THINKING_BUDGET, - THINKING_MODELS, -) - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 +from .const import CONF_PROMPT, DOMAIN +from .entity import AnthropicBaseLLMEntity async def async_setup_entry( @@ -82,253 +30,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> ToolParam: - """Format tool specification.""" - return ToolParam( - name=tool.name, - description=tool.description or "", - input_schema=convert(tool.parameters, custom_serializer=custom_serializer), - ) - - -def _convert_content( - chat_content: Iterable[conversation.Content], -) -> list[MessageParam]: - """Transform HA chat_log content into Anthropic API format.""" - messages: list[MessageParam] = [] - - for content in chat_content: - if isinstance(content, conversation.ToolResultContent): - tool_result_block = ToolResultBlockParam( - type="tool_result", - tool_use_id=content.tool_call_id, - content=json.dumps(content.tool_result), - ) - if not messages or messages[-1]["role"] != "user": - messages.append( - MessageParam( - role="user", - content=[tool_result_block], - ) - ) - elif isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - tool_result_block, - ] - else: - messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] - elif isinstance(content, conversation.UserContent): - # Combine consequent user messages - if not messages or messages[-1]["role"] != "user": - messages.append( - MessageParam( - role="user", - content=content.content, - ) - ) - elif isinstance(messages[-1]["content"], str): - messages[-1]["content"] = [ - TextBlockParam(type="text", text=messages[-1]["content"]), - TextBlockParam(type="text", text=content.content), - ] - else: - messages[-1]["content"].append( # type: ignore[attr-defined] - TextBlockParam(type="text", text=content.content) - ) - elif isinstance(content, conversation.AssistantContent): - # Combine consequent assistant messages - if not messages or messages[-1]["role"] != "assistant": - messages.append( - MessageParam( - role="assistant", - content=[], - ) - ) - - if content.content: - messages[-1]["content"].append( # type: ignore[union-attr] - TextBlockParam(type="text", text=content.content) - ) - if content.tool_calls: - messages[-1]["content"].extend( # type: ignore[union-attr] - [ - ToolUseBlockParam( - type="tool_use", - id=tool_call.id, - name=tool_call.tool_name, - input=tool_call.tool_args, - ) - for tool_call in content.tool_calls - ] - ) - else: - # Note: We don't pass SystemContent here as its passed to the API as the prompt - raise TypeError(f"Unexpected content type: {type(content)}") - - return messages - - -async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place - chat_log: conversation.ChatLog, - result: AsyncStream[MessageStreamEvent], - messages: list[MessageParam], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - A typical stream of responses might look something like the following: - - RawMessageStartEvent with no content - - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) - - RawContentBlockDeltaEvent with a ThinkingDelta - - RawContentBlockDeltaEvent with a ThinkingDelta - - RawContentBlockDeltaEvent with a ThinkingDelta - - ... - - RawContentBlockDeltaEvent with a SignatureDelta - - RawContentBlockStopEvent - - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) - - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) - - RawContentBlockStartEvent with an empty TextBlock - - RawContentBlockDeltaEvent with a TextDelta - - RawContentBlockDeltaEvent with a TextDelta - - RawContentBlockDeltaEvent with a TextDelta - - ... - - RawContentBlockStopEvent - - RawContentBlockStartEvent with ToolUseBlock specifying the function name - - RawContentBlockDeltaEvent with a InputJSONDelta - - RawContentBlockDeltaEvent with a InputJSONDelta - - ... - - RawContentBlockStopEvent - - RawMessageDeltaEvent with a stop_reason='tool_use' - - RawMessageStopEvent(type='message_stop') - - Each message could contain multiple blocks of the same type. - """ - if result is None: - raise TypeError("Expected a stream of messages") - - current_message: MessageParam | None = None - current_block: ( - TextBlockParam - | ToolUseBlockParam - | ThinkingBlockParam - | RedactedThinkingBlockParam - | None - ) = None - current_tool_args: str - input_usage: Usage | None = None - - async for response in result: - LOGGER.debug("Received response: %s", response) - - if isinstance(response, RawMessageStartEvent): - if response.message.role != "assistant": - raise ValueError("Unexpected message role") - current_message = MessageParam(role=response.message.role, content=[]) - input_usage = response.message.usage - elif isinstance(response, RawContentBlockStartEvent): - if isinstance(response.content_block, ToolUseBlock): - current_block = ToolUseBlockParam( - type="tool_use", - id=response.content_block.id, - name=response.content_block.name, - input="", - ) - current_tool_args = "" - elif isinstance(response.content_block, TextBlock): - current_block = TextBlockParam( - type="text", text=response.content_block.text - ) - yield {"role": "assistant"} - if response.content_block.text: - yield {"content": response.content_block.text} - elif isinstance(response.content_block, ThinkingBlock): - current_block = ThinkingBlockParam( - type="thinking", - thinking=response.content_block.thinking, - signature=response.content_block.signature, - ) - elif isinstance(response.content_block, RedactedThinkingBlock): - current_block = RedactedThinkingBlockParam( - type="redacted_thinking", data=response.content_block.data - ) - LOGGER.debug( - "Some of Claude’s internal reasoning has been automatically " - "encrypted for safety reasons. This doesn’t affect the quality of " - "responses" - ) - elif isinstance(response, RawContentBlockDeltaEvent): - if current_block is None: - raise ValueError("Unexpected delta without a block") - if isinstance(response.delta, InputJSONDelta): - current_tool_args += response.delta.partial_json - elif isinstance(response.delta, TextDelta): - text_block = cast(TextBlockParam, current_block) - text_block["text"] += response.delta.text - yield {"content": response.delta.text} - elif isinstance(response.delta, ThinkingDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["thinking"] += response.delta.thinking - elif isinstance(response.delta, SignatureDelta): - thinking_block = cast(ThinkingBlockParam, current_block) - thinking_block["signature"] += response.delta.signature - elif isinstance(response, RawContentBlockStopEvent): - if current_block is None: - raise ValueError("Unexpected stop event without a current block") - if current_block["type"] == "tool_use": - # tool block - tool_args = json.loads(current_tool_args) if current_tool_args else {} - current_block["input"] = tool_args - yield { - "tool_calls": [ - llm.ToolInput( - id=current_block["id"], - tool_name=current_block["name"], - tool_args=tool_args, - ) - ] - } - elif current_block["type"] == "thinking": - # thinking block - LOGGER.debug("Thinking: %s", current_block["thinking"]) - - if current_message is None: - raise ValueError("Unexpected stop event without a current message") - current_message["content"].append(current_block) # type: ignore[union-attr] - current_block = None - elif isinstance(response, RawMessageDeltaEvent): - if (usage := response.usage) is not None: - chat_log.async_trace(_create_token_stats(input_usage, usage)) - if response.delta.stop_reason == "refusal": - raise HomeAssistantError("Potential policy violation detected") - elif isinstance(response, RawMessageStopEvent): - if current_message is not None: - messages.append(current_message) - current_message = None - - -def _create_token_stats( - input_usage: Usage | None, response_usage: MessageDeltaUsage -) -> dict[str, Any]: - """Create token stats for conversation agent tracing.""" - input_tokens = 0 - cached_input_tokens = 0 - if input_usage: - input_tokens = input_usage.input_tokens - cached_input_tokens = input_usage.cache_creation_input_tokens or 0 - output_tokens = response_usage.output_tokens - return { - "stats": { - "input_tokens": input_tokens, - "cached_input_tokens": cached_input_tokens, - "output_tokens": output_tokens, - } - } - - class AnthropicConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + AnthropicBaseLLMEntity, ): """Anthropic conversation agent.""" @@ -336,17 +41,7 @@ class AnthropicConversationEntity( def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self._attr_name = subentry.title - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - manufacturer="Anthropic", - model="Claude", - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -395,73 +90,6 @@ async def _async_handle_message( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data - - tools: list[ToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - system = chat_log.content[0] - if not isinstance(system, conversation.SystemContent): - raise TypeError("First message must be a system message") - messages = _convert_content(chat_log.content[1:]) - - client = self.entry.runtime_data - - thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "messages": messages, - "tools": tools or NOT_GIVEN, - "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), - "system": system.content, - "stream": True, - } - if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: - model_args["thinking"] = ThinkingConfigEnabledParam( - type="enabled", budget_tokens=thinking_budget - ) - else: - model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") - model_args["temperature"] = options.get( - CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE - ) - - try: - stream = await client.messages.create(**model_args) - except anthropic.AnthropicError as err: - raise HomeAssistantError( - f"Sorry, I had a problem talking to Anthropic: {err}" - ) from err - - messages.extend( - _convert_content( - [ - content - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, - _transform_stream(chat_log, stream, messages), - ) - if not isinstance(content, conversation.AssistantContent) - ] - ) - ) - - if not chat_log.unresponded_tool_results: - break - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/anthropic/entity.py b/homeassistant/components/anthropic/entity.py new file mode 100644 index 00000000000000..a28c948d28b355 --- /dev/null +++ b/homeassistant/components/anthropic/entity.py @@ -0,0 +1,393 @@ +"""Base entity for Anthropic.""" + +from collections.abc import AsyncGenerator, Callable, Iterable +import json +from typing import Any, cast + +import anthropic +from anthropic import AsyncStream +from anthropic._types import NOT_GIVEN +from anthropic.types import ( + InputJSONDelta, + MessageDeltaUsage, + MessageParam, + MessageStreamEvent, + RawContentBlockDeltaEvent, + RawContentBlockStartEvent, + RawContentBlockStopEvent, + RawMessageDeltaEvent, + RawMessageStartEvent, + RawMessageStopEvent, + RedactedThinkingBlock, + RedactedThinkingBlockParam, + SignatureDelta, + TextBlock, + TextBlockParam, + TextDelta, + ThinkingBlock, + ThinkingBlockParam, + ThinkingConfigDisabledParam, + ThinkingConfigEnabledParam, + ThinkingDelta, + ToolParam, + ToolResultBlockParam, + ToolUseBlock, + ToolUseBlockParam, + Usage, +) +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import AnthropicConfigEntry +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_TEMPERATURE, + CONF_THINKING_BUDGET, + DOMAIN, + LOGGER, + MIN_THINKING_BUDGET, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_THINKING_BUDGET, + THINKING_MODELS, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> ToolParam: + """Format tool specification.""" + return ToolParam( + name=tool.name, + description=tool.description or "", + input_schema=convert(tool.parameters, custom_serializer=custom_serializer), + ) + + +def _convert_content( + chat_content: Iterable[conversation.Content], +) -> list[MessageParam]: + """Transform HA chat_log content into Anthropic API format.""" + messages: list[MessageParam] = [] + + for content in chat_content: + if isinstance(content, conversation.ToolResultContent): + tool_result_block = ToolResultBlockParam( + type="tool_result", + tool_use_id=content.tool_call_id, + content=json.dumps(content.tool_result), + ) + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=[tool_result_block], + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + tool_result_block, + ] + else: + messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined] + elif isinstance(content, conversation.UserContent): + # Combine consequent user messages + if not messages or messages[-1]["role"] != "user": + messages.append( + MessageParam( + role="user", + content=content.content, + ) + ) + elif isinstance(messages[-1]["content"], str): + messages[-1]["content"] = [ + TextBlockParam(type="text", text=messages[-1]["content"]), + TextBlockParam(type="text", text=content.content), + ] + else: + messages[-1]["content"].append( # type: ignore[attr-defined] + TextBlockParam(type="text", text=content.content) + ) + elif isinstance(content, conversation.AssistantContent): + # Combine consequent assistant messages + if not messages or messages[-1]["role"] != "assistant": + messages.append( + MessageParam( + role="assistant", + content=[], + ) + ) + + if content.content: + messages[-1]["content"].append( # type: ignore[union-attr] + TextBlockParam(type="text", text=content.content) + ) + if content.tool_calls: + messages[-1]["content"].extend( # type: ignore[union-attr] + [ + ToolUseBlockParam( + type="tool_use", + id=tool_call.id, + name=tool_call.tool_name, + input=tool_call.tool_args, + ) + for tool_call in content.tool_calls + ] + ) + else: + # Note: We don't pass SystemContent here as its passed to the API as the prompt + raise TypeError(f"Unexpected content type: {type(content)}") + + return messages + + +async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place + chat_log: conversation.ChatLog, + result: AsyncStream[MessageStreamEvent], + messages: list[MessageParam], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + A typical stream of responses might look something like the following: + - RawMessageStartEvent with no content + - RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled) + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - RawContentBlockDeltaEvent with a ThinkingDelta + - ... + - RawContentBlockDeltaEvent with a SignatureDelta + - RawContentBlockStopEvent + - RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally) + - RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta) + - RawContentBlockStartEvent with an empty TextBlock + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - RawContentBlockDeltaEvent with a TextDelta + - ... + - RawContentBlockStopEvent + - RawContentBlockStartEvent with ToolUseBlock specifying the function name + - RawContentBlockDeltaEvent with a InputJSONDelta + - RawContentBlockDeltaEvent with a InputJSONDelta + - ... + - RawContentBlockStopEvent + - RawMessageDeltaEvent with a stop_reason='tool_use' + - RawMessageStopEvent(type='message_stop') + + Each message could contain multiple blocks of the same type. + """ + if result is None: + raise TypeError("Expected a stream of messages") + + current_message: MessageParam | None = None + current_block: ( + TextBlockParam + | ToolUseBlockParam + | ThinkingBlockParam + | RedactedThinkingBlockParam + | None + ) = None + current_tool_args: str + input_usage: Usage | None = None + + async for response in result: + LOGGER.debug("Received response: %s", response) + + if isinstance(response, RawMessageStartEvent): + if response.message.role != "assistant": + raise ValueError("Unexpected message role") + current_message = MessageParam(role=response.message.role, content=[]) + input_usage = response.message.usage + elif isinstance(response, RawContentBlockStartEvent): + if isinstance(response.content_block, ToolUseBlock): + current_block = ToolUseBlockParam( + type="tool_use", + id=response.content_block.id, + name=response.content_block.name, + input="", + ) + current_tool_args = "" + elif isinstance(response.content_block, TextBlock): + current_block = TextBlockParam( + type="text", text=response.content_block.text + ) + yield {"role": "assistant"} + if response.content_block.text: + yield {"content": response.content_block.text} + elif isinstance(response.content_block, ThinkingBlock): + current_block = ThinkingBlockParam( + type="thinking", + thinking=response.content_block.thinking, + signature=response.content_block.signature, + ) + elif isinstance(response.content_block, RedactedThinkingBlock): + current_block = RedactedThinkingBlockParam( + type="redacted_thinking", data=response.content_block.data + ) + LOGGER.debug( + "Some of Claude’s internal reasoning has been automatically " + "encrypted for safety reasons. This doesn’t affect the quality of " + "responses" + ) + elif isinstance(response, RawContentBlockDeltaEvent): + if current_block is None: + raise ValueError("Unexpected delta without a block") + if isinstance(response.delta, InputJSONDelta): + current_tool_args += response.delta.partial_json + elif isinstance(response.delta, TextDelta): + text_block = cast(TextBlockParam, current_block) + text_block["text"] += response.delta.text + yield {"content": response.delta.text} + elif isinstance(response.delta, ThinkingDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["thinking"] += response.delta.thinking + elif isinstance(response.delta, SignatureDelta): + thinking_block = cast(ThinkingBlockParam, current_block) + thinking_block["signature"] += response.delta.signature + elif isinstance(response, RawContentBlockStopEvent): + if current_block is None: + raise ValueError("Unexpected stop event without a current block") + if current_block["type"] == "tool_use": + # tool block + tool_args = json.loads(current_tool_args) if current_tool_args else {} + current_block["input"] = tool_args + yield { + "tool_calls": [ + llm.ToolInput( + id=current_block["id"], + tool_name=current_block["name"], + tool_args=tool_args, + ) + ] + } + elif current_block["type"] == "thinking": + # thinking block + LOGGER.debug("Thinking: %s", current_block["thinking"]) + + if current_message is None: + raise ValueError("Unexpected stop event without a current message") + current_message["content"].append(current_block) # type: ignore[union-attr] + current_block = None + elif isinstance(response, RawMessageDeltaEvent): + if (usage := response.usage) is not None: + chat_log.async_trace(_create_token_stats(input_usage, usage)) + if response.delta.stop_reason == "refusal": + raise HomeAssistantError("Potential policy violation detected") + elif isinstance(response, RawMessageStopEvent): + if current_message is not None: + messages.append(current_message) + current_message = None + + +def _create_token_stats( + input_usage: Usage | None, response_usage: MessageDeltaUsage +) -> dict[str, Any]: + """Create token stats for conversation agent tracing.""" + input_tokens = 0 + cached_input_tokens = 0 + if input_usage: + input_tokens = input_usage.input_tokens + cached_input_tokens = input_usage.cache_creation_input_tokens or 0 + output_tokens = response_usage.output_tokens + return { + "stats": { + "input_tokens": input_tokens, + "cached_input_tokens": cached_input_tokens, + "output_tokens": output_tokens, + } + } + + +class AnthropicBaseLLMEntity(Entity): + """Anthropic base LLM entity.""" + + def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + system = chat_log.content[0] + if not isinstance(system, conversation.SystemContent): + raise TypeError("First message must be a system message") + messages = _convert_content(chat_log.content[1:]) + + client = self.entry.runtime_data + + thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET) + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + model_args = { + "model": model, + "messages": messages, + "tools": tools or NOT_GIVEN, + "max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS), + "system": system.content, + "stream": True, + } + if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET: + model_args["thinking"] = ThinkingConfigEnabledParam( + type="enabled", budget_tokens=thinking_budget + ) + else: + model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled") + model_args["temperature"] = options.get( + CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE + ) + + try: + stream = await client.messages.create(**model_args) + except anthropic.AnthropicError as err: + raise HomeAssistantError( + f"Sorry, I had a problem talking to Anthropic: {err}" + ) from err + + messages.extend( + _convert_content( + [ + content + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, + _transform_stream(chat_log, stream, messages), + ) + if not isinstance(content, conversation.AssistantContent) + ] + ) + ) + + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/assist_satellite/__init__.py b/homeassistant/components/assist_satellite/__init__.py index 6bfbdfb33a8c14..26ce9e75428a1a 100644 --- a/homeassistant/components/assist_satellite/__init__.py +++ b/homeassistant/components/assist_satellite/__init__.py @@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cv.make_entity_service_schema( { vol.Optional("message"): str, - vol.Optional("media_id"): str, + vol.Optional("media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, } ), cv.has_at_least_one_key("message", "media_id"), @@ -81,15 +81,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_internal_announce", [AssistSatelliteEntityFeature.ANNOUNCE], ) + component.async_register_entity_service( "start_conversation", vol.All( cv.make_entity_service_schema( { vol.Optional("start_message"): str, - vol.Optional("start_media_id"): str, + vol.Optional("start_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("extra_system_prompt"): str, } ), @@ -135,9 +136,9 @@ async def handle_ask_question(call: ServiceCall) -> dict[str, Any]: { vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN), vol.Optional("question"): str, - vol.Optional("question_media_id"): str, + vol.Optional("question_media_id"): _media_id_validator, vol.Optional("preannounce"): bool, - vol.Optional("preannounce_media_id"): str, + vol.Optional("preannounce_media_id"): _media_id_validator, vol.Optional("answers"): [ { vol.Required("id"): str, @@ -204,3 +205,20 @@ def has_one_non_empty_item(value: list[str]) -> list[str]: raise vol.Invalid("sentences cannot be empty") return value + + +# Validator for media_id fields that accepts both string and media selector format +_media_id_validator = vol.Any( + cv.string, # Plain string format + vol.All( + vol.Schema( + { + vol.Required("media_content_id"): cv.string, + vol.Required("media_content_type"): cv.string, + vol.Remove("metadata"): dict, # Ignore metadata if present + } + ), + # Extract media_content_id from media selector format + lambda x: x["media_content_id"], + ), +) diff --git a/homeassistant/components/assist_satellite/services.yaml b/homeassistant/components/assist_satellite/services.yaml index 6beb099186189b..8433eb6102d03f 100644 --- a/homeassistant/components/assist_satellite/services.yaml +++ b/homeassistant/components/assist_satellite/services.yaml @@ -14,7 +14,9 @@ announce: media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -23,7 +25,9 @@ announce: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* start_conversation: target: entity: @@ -40,7 +44,9 @@ start_conversation: start_media_id: required: false selector: - text: + media: + accept: + - audio/* extra_system_prompt: required: false selector: @@ -53,7 +59,9 @@ start_conversation: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* ask_question: fields: entity_id: @@ -72,7 +80,9 @@ ask_question: question_media_id: required: false selector: - text: + media: + accept: + - audio/* preannounce: required: false default: true @@ -81,7 +91,9 @@ ask_question: preannounce_media_id: required: false selector: - text: + media: + accept: + - audio/* answers: required: false selector: diff --git a/homeassistant/components/bosch_alarm/__init__.py b/homeassistant/components/bosch_alarm/__init__.py index 7f37476f1bbc63..c442c921a6b6a9 100644 --- a/homeassistant/components/bosch_alarm/__init__.py +++ b/homeassistant/components/bosch_alarm/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN -from .services import setup_services +from .services import async_setup_services from .types import BoschAlarmConfigEntry CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -29,7 +29,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up bosch alarm services.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index 5d9a5f5645f1a7..acdecbda305dfb 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util @@ -66,7 +66,8 @@ async def async_set_panel_date(call: ServiceCall) -> None: ) from err -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the services for the bosch alarm integration.""" hass.services.async_register( diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 3411882b725986..1eb6b4f2e443be 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -12,7 +12,7 @@ from .const import DOMAIN, LOGGER, PLATFORMS from .convert_config import convert_config from .panel import async_register_dynalite_frontend -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -21,7 +21,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - setup_services(hass) + async_setup_services(hass) await async_register_dynalite_frontend(hass) diff --git a/homeassistant/components/dynalite/services.py b/homeassistant/components/dynalite/services.py index d0d57a582b4f78..2621df61853f72 100644 --- a/homeassistant/components/dynalite/services.py +++ b/homeassistant/components/dynalite/services.py @@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None: @callback -def setup_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Set up the Dynalite platform.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/guardian/__init__.py b/homeassistant/components/guardian/__init__.py index 65f5525d58797d..192cb62f5df1b0 100644 --- a/homeassistant/components/guardian/__init__.py +++ b/homeassistant/components/guardian/__init__.py @@ -27,7 +27,7 @@ SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED, ) from .coordinator import GuardianDataUpdateCoordinator -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -55,7 +55,7 @@ class GuardianData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Elexa Guardian component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/guardian/services.py b/homeassistant/components/guardian/services.py index 288c6becbee988..927be7c54a5388 100644 --- a/homeassistant/components/guardian/services.py +++ b/homeassistant/components/guardian/services.py @@ -122,8 +122,9 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None: ) -def setup_services(hass: HomeAssistant) -> None: - """Register the Renault services.""" +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the guardian services.""" for service_name, schema, method in ( ( SERVICE_NAME_PAIR_SENSOR, diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index 4df1a2fa0e1a94..54510540f2abe5 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -9,9 +9,9 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType -from . import services from .const import DOMAIN from .coordinator import HeosConfigEntry, HeosCoordinator +from .services import async_setup_services PLATFORMS = [Platform.MEDIA_PLAYER] @@ -22,7 +22,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HEOS component.""" - services.register(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py index 86c6f6d05333da..e42e2bf27a2867 100644 --- a/homeassistant/components/heos/services.py +++ b/homeassistant/components/heos/services.py @@ -9,7 +9,7 @@ from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse +from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import ( config_validation as cv, @@ -44,7 +44,8 @@ HEOS_SIGN_OUT_SCHEMA = vol.Schema({}) -def register(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register HEOS services.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 01f2acd1851423..4a48d1f1ad77ad 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -23,7 +23,7 @@ from .api import AsyncConfigEntryAuth from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator -from .services import register_actions +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" - register_actions(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/home_connect/services.py b/homeassistant/components/home_connect/services.py index fac1c5fe1a9ecb..09c2f4a967de19 100644 --- a/homeassistant/components/home_connect/services.py +++ b/homeassistant/components/home_connect/services.py @@ -18,7 +18,7 @@ import voluptuous as vol from homeassistant.const import ATTR_DEVICE_ID -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -522,7 +522,8 @@ async def async_service_start_program(call: ServiceCall) -> None: await _async_service_program(call, True) -def register_actions(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register custom actions.""" hass.services.async_register( diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index a5e35749e1b555..3263b091ad598a 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -27,6 +27,7 @@ ) from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -67,6 +68,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task | None = None + self.installing_firmware_name: str | None = None def _get_translation_placeholders(self) -> dict[str, str]: """Shared translation placeholders.""" @@ -152,8 +154,12 @@ async def _install_firmware_step( assert self._device is not None if not self.firmware_install_task: - # We 100% need to install new firmware only if the wrong firmware is - # currently installed + # Keep track of the firmware we're working with, for error messages + self.installing_firmware_name = firmware_name + + # Installing new firmware is only truly required if the wrong type is + # installed: upgrading to the latest release of the current firmware type + # isn't strictly necessary for functionality. firmware_install_required = self._probed_firmware_info is None or ( self._probed_firmware_info.firmware_type != expected_installed_firmware_type @@ -167,7 +173,7 @@ async def _install_firmware_step( fw_manifest = next( fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) ) - except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + except (StopIteration, TimeoutError, ClientError, ManifestMissing): _LOGGER.warning( "Failed to fetch firmware update manifest", exc_info=True ) @@ -179,13 +185,9 @@ async def _install_firmware_step( ) return self.async_show_progress_done(next_step_id=next_step_id) - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) if not firmware_install_required: assert self._probed_firmware_info is not None @@ -205,7 +207,7 @@ async def _install_firmware_step( try: fw_data = await client.async_fetch_firmware(fw_manifest) - except (TimeoutError, ClientError, ValueError) as err: + except (TimeoutError, ClientError, ValueError): _LOGGER.warning("Failed to fetch firmware update", exc_info=True) # If we cannot download new firmware, we shouldn't block setup @@ -216,13 +218,9 @@ async def _install_firmware_step( return self.async_show_progress_done(next_step_id=next_step_id) # Otherwise, fail - raise AbortFlow( - "fw_download_failed", - description_placeholders={ - **self._get_translation_placeholders(), - "firmware_name": firmware_name, - }, - ) from err + return self.async_show_progress_done( + next_step_id="firmware_download_failed" + ) self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( @@ -249,8 +247,40 @@ async def _install_firmware_step( progress_task=self.firmware_install_task, ) + try: + await self.firmware_install_task + except HomeAssistantError: + _LOGGER.exception("Failed to flash firmware") + return self.async_show_progress_done(next_step_id="firmware_install_failed") + return self.async_show_progress_done(next_step_id=next_step_id) + async def async_step_firmware_download_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware download failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + + async def async_step_firmware_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Abort when firmware install failed.""" + assert self.installing_firmware_name is not None + return self.async_abort( + reason="fw_install_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": self.installing_firmware_name, + }, + ) + async def async_step_pick_firmware_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index d9c086cb0400f7..da2374de57bfb5 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -37,7 +37,8 @@ "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", - "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.", + "fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index f87a45febe4a46..13775d1f1eb449 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -93,7 +93,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -147,7 +148,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index b43f890b4e3c4e..d0c5e969d111f4 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -118,7 +118,8 @@ "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", - "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]", + "fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/ista_ecotrend/util.py b/homeassistant/components/ista_ecotrend/util.py index db64dbf85dbe33..5d790a3cf1c160 100644 --- a/homeassistant/components/ista_ecotrend/util.py +++ b/homeassistant/components/ista_ecotrend/util.py @@ -108,22 +108,22 @@ def get_statistics( if monthly_consumptions := get_consumptions(data, value_type): return [ { - "value": as_number( - get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get( - "additionalValue" - if value_type == IstaValueType.ENERGY - else "value" - ) - ), + "value": as_number(value), "date": consumptions["date"], } for consumptions in monthly_consumptions - if get_values_by_type( - consumptions=consumptions, - consumption_type=consumption_type, - ).get("additionalValue" if value_type == IstaValueType.ENERGY else "value") + if ( + value := ( + consumption := get_values_by_type( + consumptions=consumptions, + consumption_type=consumption_type, + ) + ).get( + "additionalValue" + if value_type == IstaValueType.ENERGY + and consumption.get("additionalValue") is not None + else "value" + ) + ) ] return None diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 8ad16642e45876..470f7891292d83 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -91,7 +91,7 @@ TimeSchema, WeatherSchema, ) -from .services import register_knx_services +from .services import async_setup_services from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams from .websocket import register_panel @@ -138,7 +138,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := config.get(DOMAIN)) is not None: hass.data[_KNX_YAML_CONFIG] = dict(conf) - register_knx_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/knx/services.py b/homeassistant/components/knx/services.py index 7b8c7ec23717cb..04803e140fd3fa 100644 --- a/homeassistant/components/knx/services.py +++ b/homeassistant/components/knx/services.py @@ -41,7 +41,7 @@ @callback -def register_knx_services(hass: HomeAssistant) -> None: +def async_setup_services(hass: HomeAssistant) -> None: """Register KNX integration services.""" hass.services.async_register( DOMAIN, diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index 85f08bb4d8706a..f523de71f6a74d 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -45,7 +45,7 @@ from homeassistant.util.json import JsonObjectType, load_json_object from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML -from .services import register_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: config[CONF_COMMANDS], ) - register_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/matrix/services.py b/homeassistant/components/matrix/services.py index edd312348d6a8a..f89a9e7b7fca9e 100644 --- a/homeassistant/components/matrix/services.py +++ b/homeassistant/components/matrix/services.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( @@ -50,7 +50,8 @@ async def _handle_send_message(call: ServiceCall) -> None: await matrix_bot.handle_send_message(call) -def register_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Set up the Matrix bot component.""" hass.services.async_register( diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py index beedb61f942acc..ae4de7d48a1443 100644 --- a/homeassistant/components/ollama/conversation.py +++ b/homeassistant/components/ollama/conversation.py @@ -2,41 +2,18 @@ from __future__ import annotations -from collections.abc import AsyncGenerator, AsyncIterator, Callable -import json -import logging -from typing import Any, Literal - -import ollama -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation 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 -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OllamaConfigEntry -from .const import ( - CONF_KEEP_ALIVE, - CONF_MAX_HISTORY, - CONF_MODEL, - CONF_NUM_CTX, - CONF_PROMPT, - CONF_THINK, - DEFAULT_KEEP_ALIVE, - DEFAULT_MAX_HISTORY, - DEFAULT_NUM_CTX, - DOMAIN, -) -from .models import MessageHistory, MessageRole - -# Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_PROMPT, DOMAIN +from .entity import OllamaBaseLLMEntity async def async_setup_entry( @@ -55,129 +32,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> dict[str, Any]: - """Format tool specification.""" - tool_spec = { - "name": tool.name, - "parameters": convert(tool.parameters, custom_serializer=custom_serializer), - } - if tool.description: - tool_spec["description"] = tool.description - return {"type": "function", "function": tool_spec} - - -def _fix_invalid_arguments(value: Any) -> Any: - """Attempt to repair incorrectly formatted json function arguments. - - Small models (for example llama3.1 8B) may produce invalid argument values - which we attempt to repair here. - """ - if not isinstance(value, str): - return value - if (value.startswith("[") and value.endswith("]")) or ( - value.startswith("{") and value.endswith("}") - ): - try: - return json.loads(value) - except json.decoder.JSONDecodeError: - pass - return value - - -def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: - """Rewrite ollama tool arguments. - - This function improves tool use quality by fixing common mistakes made by - small local tool use models. This will repair invalid json arguments and - omit unnecessary arguments with empty values that will fail intent parsing. - """ - return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} - - -def _convert_content( - chat_content: ( - conversation.Content - | conversation.ToolResultContent - | conversation.AssistantContent - ), -) -> ollama.Message: - """Create tool response content.""" - if isinstance(chat_content, conversation.ToolResultContent): - return ollama.Message( - role=MessageRole.TOOL.value, - content=json.dumps(chat_content.tool_result), - ) - if isinstance(chat_content, conversation.AssistantContent): - return ollama.Message( - role=MessageRole.ASSISTANT.value, - content=chat_content.content, - tool_calls=[ - ollama.Message.ToolCall( - function=ollama.Message.ToolCall.Function( - name=tool_call.tool_name, - arguments=tool_call.tool_args, - ) - ) - for tool_call in chat_content.tool_calls or () - ], - ) - if isinstance(chat_content, conversation.UserContent): - return ollama.Message( - role=MessageRole.USER.value, - content=chat_content.content, - ) - if isinstance(chat_content, conversation.SystemContent): - return ollama.Message( - role=MessageRole.SYSTEM.value, - content=chat_content.content, - ) - raise TypeError(f"Unexpected content type: {type(chat_content)}") - - -async def _transform_stream( - result: AsyncIterator[ollama.ChatResponse], -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform the response stream into HA format. - - An Ollama streaming response may come in chunks like this: - - response: message=Message(role="assistant", content="Paris") - response: message=Message(role="assistant", content=".") - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - response: message=Message(role="assistant", tool_calls=[...]) - response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" - - This generator conforms to the chatlog delta stream expectations in that it - yields deltas, then the role only once the response is done. - """ - - new_msg = True - async for response in result: - _LOGGER.debug("Received response: %s", response) - response_message = response["message"] - chunk: conversation.AssistantContentDeltaDict = {} - if new_msg: - new_msg = False - chunk["role"] = "assistant" - if (tool_calls := response_message.get("tool_calls")) is not None: - chunk["tool_calls"] = [ - llm.ToolInput( - tool_name=tool_call["function"]["name"], - tool_args=_parse_tool_args(tool_call["function"]["arguments"]), - ) - for tool_call in tool_calls - ] - if (content := response_message.get("content")) is not None: - chunk["content"] = content - if response_message.get("done"): - new_msg = True - yield chunk - - class OllamaConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OllamaBaseLLMEntity, ): """Ollama conversation agent.""" @@ -185,17 +43,7 @@ class OllamaConversationEntity( def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self._attr_name = subentry.title - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - manufacturer="Ollama", - model=entry.data[CONF_MODEL], - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -255,89 +103,6 @@ async def _async_handle_message( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - settings = {**self.entry.data, **self.subentry.data} - - client = self.entry.runtime_data - model = settings[CONF_MODEL] - - tools: list[dict[str, Any]] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - message_history: MessageHistory = MessageHistory( - [_convert_content(content) for content in chat_log.content] - ) - max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) - self._trim_history(message_history, max_messages) - - # Get response - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - try: - response_generator = await client.chat( - model=model, - # Make a copy of the messages because we mutate the list later - messages=list(message_history.messages), - tools=tools, - stream=True, - # keep_alive requires specifying unit. In this case, seconds - keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", - options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, - think=settings.get(CONF_THINK), - ) - except (ollama.RequestError, ollama.ResponseError) as err: - _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - raise HomeAssistantError( - f"Sorry, I had a problem talking to the Ollama server: {err}" - ) from err - - message_history.messages.extend( - [ - _convert_content(content) - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(response_generator) - ) - ] - ) - - if not chat_log.unresponded_tool_results: - break - - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history. - - This sets the max history to allow a configurable size history may take - up in the context window. - - Note that some messages in the history may not be from ollama only, and - may come from other anents, so the assumptions here may not strictly hold, - but generally should be effective. - """ - if max_messages < 1: - # Keep all messages - return - - # Ignore the in progress user message - num_previous_rounds = message_history.num_user_messages - 1 - if num_previous_rounds >= max_messages: - # Trim history but keep system prompt (first message). - # Every other message should be an assistant message, so keep 2x - # message objects. Also keep the last in progress user message - num_keep = 2 * max_messages + 1 - drop_index = len(message_history.messages) - num_keep - message_history.messages = [ - message_history.messages[0], - *message_history.messages[drop_index:], - ] - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/ollama/entity.py b/homeassistant/components/ollama/entity.py new file mode 100644 index 00000000000000..a577bf7757314d --- /dev/null +++ b/homeassistant/components/ollama/entity.py @@ -0,0 +1,258 @@ +"""Base entity for the Ollama integration.""" + +from __future__ import annotations + +from collections.abc import AsyncGenerator, AsyncIterator, Callable +import json +import logging +from typing import Any + +import ollama +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OllamaConfigEntry +from .const import ( + CONF_KEEP_ALIVE, + CONF_MAX_HISTORY, + CONF_MODEL, + CONF_NUM_CTX, + CONF_THINK, + DEFAULT_KEEP_ALIVE, + DEFAULT_MAX_HISTORY, + DEFAULT_NUM_CTX, + DOMAIN, +) +from .models import MessageHistory, MessageRole + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + +_LOGGER = logging.getLogger(__name__) + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> dict[str, Any]: + """Format tool specification.""" + tool_spec = { + "name": tool.name, + "parameters": convert(tool.parameters, custom_serializer=custom_serializer), + } + if tool.description: + tool_spec["description"] = tool.description + return {"type": "function", "function": tool_spec} + + +def _fix_invalid_arguments(value: Any) -> Any: + """Attempt to repair incorrectly formatted json function arguments. + + Small models (for example llama3.1 8B) may produce invalid argument values + which we attempt to repair here. + """ + if not isinstance(value, str): + return value + if (value.startswith("[") and value.endswith("]")) or ( + value.startswith("{") and value.endswith("}") + ): + try: + return json.loads(value) + except json.decoder.JSONDecodeError: + pass + return value + + +def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]: + """Rewrite ollama tool arguments. + + This function improves tool use quality by fixing common mistakes made by + small local tool use models. This will repair invalid json arguments and + omit unnecessary arguments with empty values that will fail intent parsing. + """ + return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v} + + +def _convert_content( + chat_content: ( + conversation.Content + | conversation.ToolResultContent + | conversation.AssistantContent + ), +) -> ollama.Message: + """Create tool response content.""" + if isinstance(chat_content, conversation.ToolResultContent): + return ollama.Message( + role=MessageRole.TOOL.value, + content=json.dumps(chat_content.tool_result), + ) + if isinstance(chat_content, conversation.AssistantContent): + return ollama.Message( + role=MessageRole.ASSISTANT.value, + content=chat_content.content, + tool_calls=[ + ollama.Message.ToolCall( + function=ollama.Message.ToolCall.Function( + name=tool_call.tool_name, + arguments=tool_call.tool_args, + ) + ) + for tool_call in chat_content.tool_calls or () + ], + ) + if isinstance(chat_content, conversation.UserContent): + return ollama.Message( + role=MessageRole.USER.value, + content=chat_content.content, + ) + if isinstance(chat_content, conversation.SystemContent): + return ollama.Message( + role=MessageRole.SYSTEM.value, + content=chat_content.content, + ) + raise TypeError(f"Unexpected content type: {type(chat_content)}") + + +async def _transform_stream( + result: AsyncIterator[ollama.ChatResponse], +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform the response stream into HA format. + + An Ollama streaming response may come in chunks like this: + + response: message=Message(role="assistant", content="Paris") + response: message=Message(role="assistant", content=".") + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + response: message=Message(role="assistant", tool_calls=[...]) + response: message=Message(role="assistant", content=""), done: True, done_reason: "stop" + + This generator conforms to the chatlog delta stream expectations in that it + yields deltas, then the role only once the response is done. + """ + + new_msg = True + async for response in result: + _LOGGER.debug("Received response: %s", response) + response_message = response["message"] + chunk: conversation.AssistantContentDeltaDict = {} + if new_msg: + new_msg = False + chunk["role"] = "assistant" + if (tool_calls := response_message.get("tool_calls")) is not None: + chunk["tool_calls"] = [ + llm.ToolInput( + tool_name=tool_call["function"]["name"], + tool_args=_parse_tool_args(tool_call["function"]["arguments"]), + ) + for tool_call in tool_calls + ] + if (content := response_message.get("content")) is not None: + chunk["content"] = content + if response_message.get("done"): + new_msg = True + yield chunk + + +class OllamaBaseLLMEntity(Entity): + """Ollama base LLM entity.""" + + def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="Ollama", + model=entry.data[CONF_MODEL], + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + settings = {**self.entry.data, **self.subentry.data} + + client = self.entry.runtime_data + model = settings[CONF_MODEL] + + tools: list[dict[str, Any]] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + message_history: MessageHistory = MessageHistory( + [_convert_content(content) for content in chat_log.content] + ) + max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) + self._trim_history(message_history, max_messages) + + # Get response + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + try: + response_generator = await client.chat( + model=model, + # Make a copy of the messages because we mutate the list later + messages=list(message_history.messages), + tools=tools, + stream=True, + # keep_alive requires specifying unit. In this case, seconds + keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s", + options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)}, + think=settings.get(CONF_THINK), + ) + except (ollama.RequestError, ollama.ResponseError) as err: + _LOGGER.error("Unexpected error talking to Ollama server: %s", err) + raise HomeAssistantError( + f"Sorry, I had a problem talking to the Ollama server: {err}" + ) from err + + message_history.messages.extend( + [ + _convert_content(content) + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(response_generator) + ) + ] + ) + + if not chat_log.unresponded_tool_results: + break + + def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: + """Trims excess messages from a single history. + + This sets the max history to allow a configurable size history may take + up in the context window. + + Note that some messages in the history may not be from ollama only, and + may come from other anents, so the assumptions here may not strictly hold, + but generally should be effective. + """ + if max_messages < 1: + # Keep all messages + return + + # Ignore the in progress user message + num_previous_rounds = message_history.num_user_messages - 1 + if num_previous_rounds >= max_messages: + # Trim history but keep system prompt (first message). + # Every other message should be an assistant message, so keep 2x + # message objects. Also keep the last in progress user message + num_keep = 2 * max_messages + 1 + drop_index = len(message_history.messages) - num_keep + message_history.messages = [ + message_history.messages[0], + *message_history.messages[drop_index:], + ] diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index e590a72cadbf46..2446fab638f502 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -1,73 +1,19 @@ """Conversation support for OpenAI.""" -from collections.abc import AsyncGenerator, Callable -import json -from typing import Any, Literal, cast - -import openai -from openai._streaming import AsyncStream -from openai.types.responses import ( - EasyInputMessageParam, - FunctionToolParam, - ResponseCompletedEvent, - ResponseErrorEvent, - ResponseFailedEvent, - ResponseFunctionCallArgumentsDeltaEvent, - ResponseFunctionCallArgumentsDoneEvent, - ResponseFunctionToolCall, - ResponseFunctionToolCallParam, - ResponseIncompleteEvent, - ResponseInputParam, - ResponseOutputItemAddedEvent, - ResponseOutputItemDoneEvent, - ResponseOutputMessage, - ResponseOutputMessageParam, - ResponseReasoningItem, - ResponseReasoningItemParam, - ResponseStreamEvent, - ResponseTextDeltaEvent, - ToolParam, - WebSearchToolParam, -) -from openai.types.responses.response_input_param import FunctionCallOutput -from openai.types.responses.web_search_tool_param import UserLocation -from voluptuous_openapi import convert +from typing import Literal from homeassistant.components import assist_pipeline, conversation 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 -from homeassistant.helpers import device_registry as dr, intent, llm +from homeassistant.helpers import intent from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OpenAIConfigEntry -from .const import ( - CONF_CHAT_MODEL, - CONF_MAX_TOKENS, - CONF_PROMPT, - CONF_REASONING_EFFORT, - CONF_TEMPERATURE, - CONF_TOP_P, - CONF_WEB_SEARCH, - CONF_WEB_SEARCH_CITY, - CONF_WEB_SEARCH_CONTEXT_SIZE, - CONF_WEB_SEARCH_COUNTRY, - CONF_WEB_SEARCH_REGION, - CONF_WEB_SEARCH_TIMEZONE, - CONF_WEB_SEARCH_USER_LOCATION, - DOMAIN, - LOGGER, - RECOMMENDED_CHAT_MODEL, - RECOMMENDED_MAX_TOKENS, - RECOMMENDED_REASONING_EFFORT, - RECOMMENDED_TEMPERATURE, - RECOMMENDED_TOP_P, - RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, -) +from .const import CONF_PROMPT, DOMAIN +from .entity import OpenAIBaseLLMEntity # Max number of back and forth with the LLM to generate a response -MAX_TOOL_ITERATIONS = 10 async def async_setup_entry( @@ -86,152 +32,10 @@ async def async_setup_entry( ) -def _format_tool( - tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None -) -> FunctionToolParam: - """Format tool specification.""" - return FunctionToolParam( - type="function", - name=tool.name, - parameters=convert(tool.parameters, custom_serializer=custom_serializer), - description=tool.description, - strict=False, - ) - - -def _convert_content_to_param( - content: conversation.Content, -) -> ResponseInputParam: - """Convert any native chat message for this agent to the native format.""" - messages: ResponseInputParam = [] - if isinstance(content, conversation.ToolResultContent): - return [ - FunctionCallOutput( - type="function_call_output", - call_id=content.tool_call_id, - output=json.dumps(content.tool_result), - ) - ] - - if content.content: - role: Literal["user", "assistant", "system", "developer"] = content.role - if role == "system": - role = "developer" - messages.append( - EasyInputMessageParam(type="message", role=role, content=content.content) - ) - - if isinstance(content, conversation.AssistantContent) and content.tool_calls: - messages.extend( - ResponseFunctionToolCallParam( - type="function_call", - name=tool_call.tool_name, - arguments=json.dumps(tool_call.tool_args), - call_id=tool_call.id, - ) - for tool_call in content.tool_calls - ) - return messages - - -async def _transform_stream( - chat_log: conversation.ChatLog, - result: AsyncStream[ResponseStreamEvent], - messages: ResponseInputParam, -) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: - """Transform an OpenAI delta stream into HA format.""" - async for event in result: - LOGGER.debug("Received event: %s", event) - - if isinstance(event, ResponseOutputItemAddedEvent): - if isinstance(event.item, ResponseOutputMessage): - yield {"role": event.item.role} - elif isinstance(event.item, ResponseFunctionToolCall): - # OpenAI has tool calls as individual events - # while HA puts tool calls inside the assistant message. - # We turn them into individual assistant content for HA - # to ensure that tools are called as soon as possible. - yield {"role": "assistant"} - current_tool_call = event.item - elif isinstance(event, ResponseOutputItemDoneEvent): - item = event.item.model_dump() - item.pop("status", None) - if isinstance(event.item, ResponseReasoningItem): - messages.append(cast(ResponseReasoningItemParam, item)) - elif isinstance(event.item, ResponseOutputMessage): - messages.append(cast(ResponseOutputMessageParam, item)) - elif isinstance(event.item, ResponseFunctionToolCall): - messages.append(cast(ResponseFunctionToolCallParam, item)) - elif isinstance(event, ResponseTextDeltaEvent): - yield {"content": event.delta} - elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): - current_tool_call.arguments += event.delta - elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): - current_tool_call.status = "completed" - yield { - "tool_calls": [ - llm.ToolInput( - id=current_tool_call.call_id, - tool_name=current_tool_call.name, - tool_args=json.loads(current_tool_call.arguments), - ) - ] - } - elif isinstance(event, ResponseCompletedEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - elif isinstance(event, ResponseIncompleteEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - - if ( - event.response.incomplete_details - and event.response.incomplete_details.reason - ): - reason: str = event.response.incomplete_details.reason - else: - reason = "unknown reason" - - if reason == "max_output_tokens": - reason = "max output tokens reached" - elif reason == "content_filter": - reason = "content filter triggered" - - raise HomeAssistantError(f"OpenAI response incomplete: {reason}") - elif isinstance(event, ResponseFailedEvent): - if event.response.usage is not None: - chat_log.async_trace( - { - "stats": { - "input_tokens": event.response.usage.input_tokens, - "output_tokens": event.response.usage.output_tokens, - } - } - ) - reason = "unknown reason" - if event.response.error is not None: - reason = event.response.error.message - raise HomeAssistantError(f"OpenAI response failed: {reason}") - elif isinstance(event, ResponseErrorEvent): - raise HomeAssistantError(f"OpenAI response error: {event.message}") - - class OpenAIConversationEntity( - conversation.ConversationEntity, conversation.AbstractConversationAgent + conversation.ConversationEntity, + conversation.AbstractConversationAgent, + OpenAIBaseLLMEntity, ): """OpenAI conversation agent.""" @@ -239,17 +43,7 @@ class OpenAIConversationEntity( def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: """Initialize the agent.""" - self.entry = entry - self.subentry = subentry - self._attr_name = subentry.title - self._attr_unique_id = subentry.subentry_id - self._attr_device_info = dr.DeviceInfo( - identifiers={(DOMAIN, subentry.subentry_id)}, - name=subentry.title, - manufacturer="OpenAI", - model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), - entry_type=dr.DeviceEntryType.SERVICE, - ) + super().__init__(entry, subentry) if self.subentry.data.get(CONF_LLM_HASS_API): self._attr_supported_features = ( conversation.ConversationEntityFeature.CONTROL @@ -305,91 +99,6 @@ async def _async_handle_message( continue_conversation=chat_log.continue_conversation, ) - async def _async_handle_chat_log( - self, - chat_log: conversation.ChatLog, - ) -> None: - """Generate an answer for the chat log.""" - options = self.subentry.data - - tools: list[ToolParam] | None = None - if chat_log.llm_api: - tools = [ - _format_tool(tool, chat_log.llm_api.custom_serializer) - for tool in chat_log.llm_api.tools - ] - - if options.get(CONF_WEB_SEARCH): - web_search = WebSearchToolParam( - type="web_search_preview", - search_context_size=options.get( - CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE - ), - ) - if options.get(CONF_WEB_SEARCH_USER_LOCATION): - web_search["user_location"] = UserLocation( - type="approximate", - city=options.get(CONF_WEB_SEARCH_CITY, ""), - region=options.get(CONF_WEB_SEARCH_REGION, ""), - country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), - timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), - ) - if tools is None: - tools = [] - tools.append(web_search) - - model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) - messages = [ - m - for content in chat_log.content - for m in _convert_content_to_param(content) - ] - - client = self.entry.runtime_data - - # To prevent infinite loops, we limit the number of iterations - for _iteration in range(MAX_TOOL_ITERATIONS): - model_args = { - "model": model, - "input": messages, - "max_output_tokens": options.get( - CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS - ), - "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), - "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), - "user": chat_log.conversation_id, - "stream": True, - } - if tools: - model_args["tools"] = tools - - if model.startswith("o"): - model_args["reasoning"] = { - "effort": options.get( - CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT - ) - } - else: - model_args["store"] = False - - try: - result = await client.responses.create(**model_args) - except openai.RateLimitError as err: - LOGGER.error("Rate limited by OpenAI: %s", err) - raise HomeAssistantError("Rate limited or insufficient funds") from err - except openai.OpenAIError as err: - LOGGER.error("Error talking to OpenAI: %s", err) - raise HomeAssistantError("Error talking to OpenAI") from err - - async for content in chat_log.async_add_delta_content_stream( - self.entity_id, _transform_stream(chat_log, result, messages) - ): - if not isinstance(content, conversation.AssistantContent): - messages.extend(_convert_content_to_param(content)) - - if not chat_log.unresponded_tool_results: - break - async def _async_entry_update_listener( self, hass: HomeAssistant, entry: ConfigEntry ) -> None: diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py new file mode 100644 index 00000000000000..ba7153deb24dae --- /dev/null +++ b/homeassistant/components/openai_conversation/entity.py @@ -0,0 +1,314 @@ +"""Base entity for OpenAI.""" + +from collections.abc import AsyncGenerator, Callable +import json +from typing import Any, Literal, cast + +import openai +from openai._streaming import AsyncStream +from openai.types.responses import ( + EasyInputMessageParam, + FunctionToolParam, + ResponseCompletedEvent, + ResponseErrorEvent, + ResponseFailedEvent, + ResponseFunctionCallArgumentsDeltaEvent, + ResponseFunctionCallArgumentsDoneEvent, + ResponseFunctionToolCall, + ResponseFunctionToolCallParam, + ResponseIncompleteEvent, + ResponseInputParam, + ResponseOutputItemAddedEvent, + ResponseOutputItemDoneEvent, + ResponseOutputMessage, + ResponseOutputMessageParam, + ResponseReasoningItem, + ResponseReasoningItemParam, + ResponseStreamEvent, + ResponseTextDeltaEvent, + ToolParam, + WebSearchToolParam, +) +from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.web_search_tool_param import UserLocation +from voluptuous_openapi import convert + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigSubentry +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, llm +from homeassistant.helpers.entity import Entity + +from . import OpenAIConfigEntry +from .const import ( + CONF_CHAT_MODEL, + CONF_MAX_TOKENS, + CONF_REASONING_EFFORT, + CONF_TEMPERATURE, + CONF_TOP_P, + CONF_WEB_SEARCH, + CONF_WEB_SEARCH_CITY, + CONF_WEB_SEARCH_CONTEXT_SIZE, + CONF_WEB_SEARCH_COUNTRY, + CONF_WEB_SEARCH_REGION, + CONF_WEB_SEARCH_TIMEZONE, + CONF_WEB_SEARCH_USER_LOCATION, + DOMAIN, + LOGGER, + RECOMMENDED_CHAT_MODEL, + RECOMMENDED_MAX_TOKENS, + RECOMMENDED_REASONING_EFFORT, + RECOMMENDED_TEMPERATURE, + RECOMMENDED_TOP_P, + RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE, +) + +# Max number of back and forth with the LLM to generate a response +MAX_TOOL_ITERATIONS = 10 + + +def _format_tool( + tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None +) -> FunctionToolParam: + """Format tool specification.""" + return FunctionToolParam( + type="function", + name=tool.name, + parameters=convert(tool.parameters, custom_serializer=custom_serializer), + description=tool.description, + strict=False, + ) + + +def _convert_content_to_param( + content: conversation.Content, +) -> ResponseInputParam: + """Convert any native chat message for this agent to the native format.""" + messages: ResponseInputParam = [] + if isinstance(content, conversation.ToolResultContent): + return [ + FunctionCallOutput( + type="function_call_output", + call_id=content.tool_call_id, + output=json.dumps(content.tool_result), + ) + ] + + if content.content: + role: Literal["user", "assistant", "system", "developer"] = content.role + if role == "system": + role = "developer" + messages.append( + EasyInputMessageParam(type="message", role=role, content=content.content) + ) + + if isinstance(content, conversation.AssistantContent) and content.tool_calls: + messages.extend( + ResponseFunctionToolCallParam( + type="function_call", + name=tool_call.tool_name, + arguments=json.dumps(tool_call.tool_args), + call_id=tool_call.id, + ) + for tool_call in content.tool_calls + ) + return messages + + +async def _transform_stream( + chat_log: conversation.ChatLog, + result: AsyncStream[ResponseStreamEvent], + messages: ResponseInputParam, +) -> AsyncGenerator[conversation.AssistantContentDeltaDict]: + """Transform an OpenAI delta stream into HA format.""" + async for event in result: + LOGGER.debug("Received event: %s", event) + + if isinstance(event, ResponseOutputItemAddedEvent): + if isinstance(event.item, ResponseOutputMessage): + yield {"role": event.item.role} + elif isinstance(event.item, ResponseFunctionToolCall): + # OpenAI has tool calls as individual events + # while HA puts tool calls inside the assistant message. + # We turn them into individual assistant content for HA + # to ensure that tools are called as soon as possible. + yield {"role": "assistant"} + current_tool_call = event.item + elif isinstance(event, ResponseOutputItemDoneEvent): + item = event.item.model_dump() + item.pop("status", None) + if isinstance(event.item, ResponseReasoningItem): + messages.append(cast(ResponseReasoningItemParam, item)) + elif isinstance(event.item, ResponseOutputMessage): + messages.append(cast(ResponseOutputMessageParam, item)) + elif isinstance(event.item, ResponseFunctionToolCall): + messages.append(cast(ResponseFunctionToolCallParam, item)) + elif isinstance(event, ResponseTextDeltaEvent): + yield {"content": event.delta} + elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent): + current_tool_call.arguments += event.delta + elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent): + current_tool_call.status = "completed" + yield { + "tool_calls": [ + llm.ToolInput( + id=current_tool_call.call_id, + tool_name=current_tool_call.name, + tool_args=json.loads(current_tool_call.arguments), + ) + ] + } + elif isinstance(event, ResponseCompletedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + elif isinstance(event, ResponseIncompleteEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + + if ( + event.response.incomplete_details + and event.response.incomplete_details.reason + ): + reason: str = event.response.incomplete_details.reason + else: + reason = "unknown reason" + + if reason == "max_output_tokens": + reason = "max output tokens reached" + elif reason == "content_filter": + reason = "content filter triggered" + + raise HomeAssistantError(f"OpenAI response incomplete: {reason}") + elif isinstance(event, ResponseFailedEvent): + if event.response.usage is not None: + chat_log.async_trace( + { + "stats": { + "input_tokens": event.response.usage.input_tokens, + "output_tokens": event.response.usage.output_tokens, + } + } + ) + reason = "unknown reason" + if event.response.error is not None: + reason = event.response.error.message + raise HomeAssistantError(f"OpenAI response failed: {reason}") + elif isinstance(event, ResponseErrorEvent): + raise HomeAssistantError(f"OpenAI response error: {event.message}") + + +class OpenAIBaseLLMEntity(Entity): + """OpenAI conversation agent.""" + + def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None: + """Initialize the entity.""" + self.entry = entry + self.subentry = subentry + self._attr_name = subentry.title + self._attr_unique_id = subentry.subentry_id + self._attr_device_info = dr.DeviceInfo( + identifiers={(DOMAIN, subentry.subentry_id)}, + name=subentry.title, + manufacturer="OpenAI", + model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + entry_type=dr.DeviceEntryType.SERVICE, + ) + + async def _async_handle_chat_log( + self, + chat_log: conversation.ChatLog, + ) -> None: + """Generate an answer for the chat log.""" + options = self.subentry.data + + tools: list[ToolParam] | None = None + if chat_log.llm_api: + tools = [ + _format_tool(tool, chat_log.llm_api.custom_serializer) + for tool in chat_log.llm_api.tools + ] + + if options.get(CONF_WEB_SEARCH): + web_search = WebSearchToolParam( + type="web_search_preview", + search_context_size=options.get( + CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE + ), + ) + if options.get(CONF_WEB_SEARCH_USER_LOCATION): + web_search["user_location"] = UserLocation( + type="approximate", + city=options.get(CONF_WEB_SEARCH_CITY, ""), + region=options.get(CONF_WEB_SEARCH_REGION, ""), + country=options.get(CONF_WEB_SEARCH_COUNTRY, ""), + timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""), + ) + if tools is None: + tools = [] + tools.append(web_search) + + model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL) + messages = [ + m + for content in chat_log.content + for m in _convert_content_to_param(content) + ] + + client = self.entry.runtime_data + + # To prevent infinite loops, we limit the number of iterations + for _iteration in range(MAX_TOOL_ITERATIONS): + model_args = { + "model": model, + "input": messages, + "max_output_tokens": options.get( + CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS + ), + "top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P), + "temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE), + "user": chat_log.conversation_id, + "stream": True, + } + if tools: + model_args["tools"] = tools + + if model.startswith("o"): + model_args["reasoning"] = { + "effort": options.get( + CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT + ) + } + else: + model_args["store"] = False + + try: + result = await client.responses.create(**model_args) + except openai.RateLimitError as err: + LOGGER.error("Rate limited by OpenAI: %s", err) + raise HomeAssistantError("Rate limited or insufficient funds") from err + except openai.OpenAIError as err: + LOGGER.error("Error talking to OpenAI: %s", err) + raise HomeAssistantError("Error talking to OpenAI") from err + + async for content in chat_log.async_add_delta_content_stream( + self.entity_id, _transform_stream(chat_log, result, messages) + ): + if not isinstance(content, conversation.AssistantContent): + messages.extend(_convert_content_to_param(content)) + + if not chat_log.unresponded_tool_results: + break diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index 68463e764f27b8..c7e107b1637b96 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -21,6 +21,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -30,6 +31,7 @@ CONF_SET_PRECISION, DATA_GATEWAYS, DATA_OPENTHERM_GW, + DOMAIN, THERMOSTAT_DEVICE_DESCRIPTION, OpenThermDataSource, ) @@ -75,7 +77,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_hvac_modes = [] + _attr_hvac_modes = [HVACMode.HEAT] _attr_name = None _attr_preset_modes = [] _attr_min_temp = 1 @@ -129,9 +131,11 @@ def receive_report(self, status: dict[OpenThermDataSource, dict]): if ch_active and flame_on: self._attr_hvac_action = HVACAction.HEATING self._attr_hvac_mode = HVACMode.HEAT + self._attr_hvac_modes = [HVACMode.HEAT] elif cooling_active: self._attr_hvac_action = HVACAction.COOLING self._attr_hvac_mode = HVACMode.COOL + self._attr_hvac_modes = [HVACMode.COOL] else: self._attr_hvac_action = HVACAction.IDLE @@ -182,6 +186,13 @@ def preset_mode(self) -> str: return PRESET_AWAY return PRESET_NONE + def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="change_hvac_mode_not_supported", + ) + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") diff --git a/homeassistant/components/opentherm_gw/strings.json b/homeassistant/components/opentherm_gw/strings.json index 8959e0facf9ca9..f3938c81e7ecb6 100644 --- a/homeassistant/components/opentherm_gw/strings.json +++ b/homeassistant/components/opentherm_gw/strings.json @@ -355,6 +355,9 @@ } }, "exceptions": { + "change_hvac_mode_not_supported": { + "message": "Changing HVAC mode is not supported." + }, "invalid_gateway_id": { "message": "Gateway {gw_id} not found or not loaded!" } diff --git a/homeassistant/components/playstation_network/coordinator.py b/homeassistant/components/playstation_network/coordinator.py index 2581a016feb4c6..69cc95d1d491e1 100644 --- a/homeassistant/components/playstation_network/coordinator.py +++ b/homeassistant/components/playstation_network/coordinator.py @@ -7,13 +7,13 @@ from psnawp_api.core.psnawp_exceptions import ( PSNAWPAuthenticationError, + PSNAWPClientError, PSNAWPServerError, ) -from psnawp_api.models.user import User from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -28,7 +28,6 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData """Data update coordinator for PSN.""" config_entry: PlaystationNetworkConfigEntry - user: User def __init__( self, @@ -51,12 +50,17 @@ async def _async_setup(self) -> None: """Set up the coordinator.""" try: - self.user = await self.psn.get_user() + await self.psn.get_user() except PSNAWPAuthenticationError as error: raise ConfigEntryAuthFailed( translation_domain=DOMAIN, translation_key="not_ready", ) from error + except (PSNAWPServerError, PSNAWPClientError) as error: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="update_failed", + ) from error async def _async_update_data(self) -> PlaystationNetworkData: """Get the latest data from the PSN.""" @@ -67,7 +71,7 @@ async def _async_update_data(self) -> PlaystationNetworkData: translation_domain=DOMAIN, translation_key="not_ready", ) from error - except PSNAWPServerError as error: + except (PSNAWPServerError, PSNAWPClientError) as error: raise UpdateFailed( translation_domain=DOMAIN, translation_key="update_failed", diff --git a/homeassistant/components/playstation_network/icons.json b/homeassistant/components/playstation_network/icons.json index a05170f78d3d07..7817a4c8b0791e 100644 --- a/homeassistant/components/playstation_network/icons.json +++ b/homeassistant/components/playstation_network/icons.json @@ -26,6 +26,9 @@ }, "online_id": { "default": "mdi:account" + }, + "last_online": { + "default": "mdi:account-clock" } } } diff --git a/homeassistant/components/playstation_network/sensor.py b/homeassistant/components/playstation_network/sensor.py index b4563b00f25817..6af305d3ce729e 100644 --- a/homeassistant/components/playstation_network/sensor.py +++ b/homeassistant/components/playstation_network/sensor.py @@ -4,16 +4,22 @@ from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from enum import StrEnum from typing import TYPE_CHECKING -from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util import dt as dt_util from .const import DOMAIN from .coordinator import ( @@ -29,7 +35,7 @@ class PlaystationNetworkSensorEntityDescription(SensorEntityDescription): """PlayStation Network sensor description.""" - value_fn: Callable[[PlaystationNetworkData], StateType] + value_fn: Callable[[PlaystationNetworkData], StateType | datetime] entity_picture: str | None = None @@ -43,6 +49,7 @@ class PlaystationNetworkSensor(StrEnum): EARNED_TROPHIES_SILVER = "earned_trophies_silver" EARNED_TROPHIES_BRONZE = "earned_trophies_bronze" ONLINE_ID = "online_id" + LAST_ONLINE = "last_online" SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = ( @@ -102,6 +109,16 @@ class PlaystationNetworkSensor(StrEnum): translation_key=PlaystationNetworkSensor.ONLINE_ID, value_fn=lambda psn: psn.username, ), + PlaystationNetworkSensorEntityDescription( + key=PlaystationNetworkSensor.LAST_ONLINE, + translation_key=PlaystationNetworkSensor.LAST_ONLINE, + value_fn=( + lambda psn: dt_util.parse_datetime( + psn.presence["basicPresence"]["lastAvailableDate"] + ) + ), + device_class=SensorDeviceClass.TIMESTAMP, + ), ) @@ -147,7 +164,7 @@ def __init__( ) @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state of the sensor.""" return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/playstation_network/strings.json b/homeassistant/components/playstation_network/strings.json index a26f45d8973dc0..aee4dc0d7370b4 100644 --- a/homeassistant/components/playstation_network/strings.json +++ b/homeassistant/components/playstation_network/strings.json @@ -78,6 +78,9 @@ }, "online_id": { "name": "Online-ID" + }, + "last_online": { + "name": "Last online" } } } diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 48bab1f5c8b8a1..da3769654c4094 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -12,7 +12,7 @@ from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub -from .services import setup_services +from .services import async_setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) type RenaultConfigEntry = ConfigEntry[RenaultHub] @@ -20,7 +20,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Renault component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index dfad97ae4ead83..df85ad57f668a2 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -8,7 +8,7 @@ import voluptuous as vol -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, device_registry as dr @@ -191,7 +191,8 @@ def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy: ) -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register the Renault services.""" hass.services.async_register( diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 7b441889b8cdad..b6480b84f64972 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -328,6 +328,9 @@ async def async_step_user( # validate connection to Telegram API errors: dict[str, str] = {} + user_input[CONF_PROXY_URL] = user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ) bot_name = await self._validate_bot( user_input, errors, description_placeholders ) @@ -350,7 +353,9 @@ async def async_step_user( data={ CONF_PLATFORM: user_input[CONF_PLATFORM], CONF_API_KEY: user_input[CONF_API_KEY], - CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL), + CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get( + CONF_PROXY_URL + ), }, options={ # this value may come from yaml import diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 35c61892964c90..055fd5e227795a 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import setup_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def _migrate_device_identifiers(hass: HomeAssistant, entry_id: str) -> None: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the actions for the Velbus component.""" - setup_services(hass) + async_setup_services(hass) return True diff --git a/homeassistant/components/velbus/services.py b/homeassistant/components/velbus/services.py index 765c5a0f6747b5..5fccbcaf82e4c9 100644 --- a/homeassistant/components/velbus/services.py +++ b/homeassistant/components/velbus/services.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import config_validation as cv, selector from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -32,7 +32,8 @@ ) -def setup_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register the velbus services.""" def check_entry_id(interface: str) -> str: diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 34d17e5227561f..1059a41db536f6 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -22,6 +22,8 @@ CHARGER_CURRENT_VERSION_KEY = "currentVersion" CHARGER_CURRENCY_KEY = "currency" CHARGER_DATA_KEY = "config_data" +CHARGER_DATA_POST_L1_KEY = "data" +CHARGER_DATA_POST_L2_KEY = "chargerData" CHARGER_DEPOT_PRICE_KEY = "depot_price" CHARGER_ENERGY_PRICE_KEY = "energy_price" CHARGER_FEATURES_KEY = "features" @@ -32,7 +34,9 @@ CHARGER_SOFTWARE_KEY = "software" CHARGER_MAX_AVAILABLE_POWER_KEY = "max_available_power" CHARGER_MAX_CHARGING_CURRENT_KEY = "max_charging_current" +CHARGER_MAX_CHARGING_CURRENT_POST_KEY = "maxChargingCurrent" CHARGER_MAX_ICP_CURRENT_KEY = "icp_max_current" +CHARGER_MAX_ICP_CURRENT_POST_KEY = "maxAvailableCurrent" CHARGER_PAUSE_RESUME_KEY = "paused" CHARGER_LOCKED_UNLOCKED_KEY = "locked" CHARGER_NAME_KEY = "name" diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index 598bfa7429ae43..69bf3a3af1caee 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -14,11 +14,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CHARGER_CURRENCY_KEY, CHARGER_DATA_KEY, + CHARGER_DATA_POST_L1_KEY, + CHARGER_DATA_POST_L2_KEY, CHARGER_ECO_SMART_KEY, CHARGER_ECO_SMART_MODE_KEY, CHARGER_ECO_SMART_STATUS_KEY, @@ -26,6 +28,7 @@ CHARGER_FEATURES_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_MAX_CHARGING_CURRENT_POST_KEY, CHARGER_MAX_ICP_CURRENT_KEY, CHARGER_PLAN_KEY, CHARGER_POWER_BOOST_KEY, @@ -192,10 +195,10 @@ def _get_data(self) -> dict[str, Any]: return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="too_many_requests" ) from wallbox_connection_error - raise HomeAssistantError( + raise UpdateFailed( translation_domain=DOMAIN, translation_key="api_failed" ) from wallbox_connection_error @@ -204,10 +207,19 @@ async def _async_update_data(self) -> dict[str, Any]: return await self.hass.async_add_executor_job(self._get_data) @_require_authentication - def _set_charging_current(self, charging_current: float) -> None: + def _set_charging_current( + self, charging_current: float + ) -> dict[str, dict[str, dict[str, Any]]]: """Set maximum charging current for Wallbox.""" try: - self._wallbox.setMaxChargingCurrent(self._station, charging_current) + result = self._wallbox.setMaxChargingCurrent( + self._station, charging_current + ) + data = self.data + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_MAX_CHARGING_CURRENT_POST_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -221,16 +233,19 @@ def _set_charging_current(self, charging_current: float) -> None: async def async_set_charging_current(self, charging_current: float) -> None: """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( self._set_charging_current, charging_current ) - await self.async_request_refresh() + self.async_set_updated_data(data) @_require_authentication - def _set_icp_current(self, icp_current: float) -> None: + def _set_icp_current(self, icp_current: float) -> dict[str, Any]: """Set maximum icp current for Wallbox.""" try: - self._wallbox.setIcpMaxCurrent(self._station, icp_current) + result = self._wallbox.setIcpMaxCurrent(self._station, icp_current) + data = self.data + data[CHARGER_MAX_ICP_CURRENT_KEY] = result[CHARGER_MAX_ICP_CURRENT_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -244,14 +259,19 @@ def _set_icp_current(self, icp_current: float) -> None: async def async_set_icp_current(self, icp_current: float) -> None: """Set maximum icp current for Wallbox.""" - await self.hass.async_add_executor_job(self._set_icp_current, icp_current) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_icp_current, icp_current + ) + self.async_set_updated_data(data) @_require_authentication - def _set_energy_cost(self, energy_cost: float) -> None: + def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]: """Set energy cost for Wallbox.""" try: - self._wallbox.setEnergyCost(self._station, energy_cost) + result = self._wallbox.setEnergyCost(self._station, energy_cost) + data = self.data + data[CHARGER_ENERGY_PRICE_KEY] = result[CHARGER_ENERGY_PRICE_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 429: raise HomeAssistantError( @@ -263,17 +283,24 @@ def _set_energy_cost(self, energy_cost: float) -> None: async def async_set_energy_cost(self, energy_cost: float) -> None: """Set energy cost for Wallbox.""" - await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job( + self._set_energy_cost, energy_cost + ) + self.async_set_updated_data(data) @_require_authentication - def _set_lock_unlock(self, lock: bool) -> None: + def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]: """Set wallbox to locked or unlocked.""" try: if lock: - self._wallbox.lockCharger(self._station) + result = self._wallbox.lockCharger(self._station) else: - self._wallbox.unlockCharger(self._station) + result = self._wallbox.unlockCharger(self._station) + data = self.data + data[CHARGER_LOCKED_UNLOCKED_KEY] = result[CHARGER_DATA_POST_L1_KEY][ + CHARGER_DATA_POST_L2_KEY + ][CHARGER_LOCKED_UNLOCKED_KEY] + return data # noqa: TRY300 except requests.exceptions.HTTPError as wallbox_connection_error: if wallbox_connection_error.response.status_code == 403: raise InvalidAuth from wallbox_connection_error @@ -287,8 +314,8 @@ def _set_lock_unlock(self, lock: bool) -> None: async def async_set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() + data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + self.async_set_updated_data(data) @_require_authentication def _pause_charger(self, pause: bool) -> None: diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 7acc56f67f2112..7b5c99340f8d46 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -7,7 +7,6 @@ from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -16,7 +15,7 @@ CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { @@ -34,16 +33,6 @@ async def async_setup_entry( ) -> None: """Create wallbox lock entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to lock, if so, add lock component - try: - await coordinator.async_set_lock_unlock( - coordinator.data[CHARGER_LOCKED_UNLOCKED_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxLock(coordinator, description) for ent in coordinator.data diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 8077347858244f..e1b044bbdb2c87 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -12,7 +12,6 @@ from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import ( @@ -26,7 +25,7 @@ CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) -from .coordinator import InvalidAuth, WallboxCoordinator +from .coordinator import WallboxCoordinator from .entity import WallboxEntity @@ -86,16 +85,6 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user has sufficient rights to change values, if so, add number component: - try: - await coordinator.async_set_charging_current( - coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) - except InvalidAuth: - return - except HomeAssistantError as exc: - raise PlatformNotReady from exc - async_add_entities( WallboxNumber(coordinator, entry, description) for ent in coordinator.data diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 2b87da630a0de6..2897fbbdb165f1 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -24,5 +24,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.39.0"] + "requirements": ["xiaomi-ble==1.1.0"] } diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 241c2729653185..27b69a8d62dcac 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN -from .services import register_services +from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -81,7 +81,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ex, ) - register_services(hass) + async_setup_services(hass) hass.async_create_task( async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config) diff --git a/homeassistant/components/zoneminder/services.py b/homeassistant/components/zoneminder/services.py index 14ce873ec14ad7..53847213c85a52 100644 --- a/homeassistant/components/zoneminder/services.py +++ b/homeassistant/components/zoneminder/services.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.const import ATTR_ID, ATTR_NAME -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import DOMAIN @@ -32,7 +32,8 @@ def _set_active_state(call: ServiceCall) -> None: ) -def register_services(hass: HomeAssistant) -> None: +@callback +def async_setup_services(hass: HomeAssistant) -> None: """Register ZoneMinder services.""" hass.services.async_register( diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index acb91ddc148bfa..e4277aac98ecbe 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -1043,9 +1043,18 @@ def __init__(self, config: MediaSelectorConfig | None = None) -> None: """Instantiate a selector.""" super().__init__(config) - def __call__(self, data: Any) -> dict[str, float]: + def __call__(self, data: Any) -> dict[str, str]: """Validate the passed selection.""" - media: dict[str, float] = self.DATA_SCHEMA(data) + schema = self.DATA_SCHEMA.schema.copy() + + if "accept" in self.config: + # If accept is set, the entity_id field will not be present + schema.pop("entity_id", None) + else: + # If accept is not set, the entity_id field is required + schema[vol.Required("entity_id")] = cv.entity_id_or_uuid + + media: dict[str, str] = self.DATA_SCHEMA(data) return media diff --git a/homeassistant/helpers/service_info/dhcp.py b/homeassistant/helpers/service_info/dhcp.py index 47479a53a8a6a2..d46c7a590045f1 100644 --- a/homeassistant/helpers/service_info/dhcp.py +++ b/homeassistant/helpers/service_info/dhcp.py @@ -12,3 +12,9 @@ class DhcpServiceInfo(BaseServiceInfo): ip: str hostname: str macaddress: str + """The MAC address of the device. + + Please note that for historical reason the DHCP service will always format it + as a lowercase string without colons. + eg. "AA:BB:CC:12:34:56" is stored as "aabbcc123456" + """ diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 6e47163e90a57a..80ced039e463a4 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -128,9 +128,11 @@ "disabled": "Disabled", "discharging": "Discharging", "disconnected": "Disconnected", + "empty": "Empty", "enabled": "Enabled", "error": "Error", "fault": "Fault", + "full": "Full", "high": "High", "home": "Home", "idle": "Idle", diff --git a/requirements_all.txt b/requirements_all.txt index 5bdfb48232fd85..afa5256265410f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -3126,7 +3126,7 @@ wyoming==1.7.1 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.39.0 +xiaomi-ble==1.1.0 # homeassistant.components.knx xknx==3.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3cf7bc78aaa96f..02ed0c645756a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2579,7 +2579,7 @@ wyoming==1.7.1 xbox-webapi==2.1.0 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.39.0 +xiaomi-ble==1.1.0 # homeassistant.components.knx xknx==3.8.0 diff --git a/tests/components/anthropic/test_conversation.py b/tests/components/anthropic/test_conversation.py index 3ae44e552cca0a..83770e7ee34a9e 100644 --- a/tests/components/anthropic/test_conversation.py +++ b/tests/components/anthropic/test_conversation.py @@ -316,7 +316,7 @@ async def test_conversation_agent( assert agent.supported_languages == "*" -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") @pytest.mark.parametrize( ("tool_call_json_parts", "expected_call_tool_args"), [ @@ -430,7 +430,7 @@ def completion_result(*args, messages, **kwargs): ) -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, @@ -760,7 +760,7 @@ async def test_redacted_thinking( assert chat_log.content[2].content == "How can I help you today?" -@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools") async def test_extended_thinking_tool_call( mock_get_tools, hass: HomeAssistant, diff --git a/tests/components/assist_satellite/test_entity.py b/tests/components/assist_satellite/test_entity.py index 3473b23beddf94..9f14be6c50ff25 100644 --- a/tests/components/assist_satellite/test_entity.py +++ b/tests/components/assist_satellite/test_entity.py @@ -235,6 +235,43 @@ async def async_pipeline_from_audio_stream(*args, **kwargs): preannounce_media_id="http://example.com/preannounce.mp3", ), ), + ( + { + "message": "Hello", + "media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + original_media_id="media-source://given", + tts_token=None, + media_id_source="media_id", + ), + ), + ( + { + "media_id": { + "media_content_id": "http://example.com/bla.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/bla.mp3", + original_media_id="http://example.com/bla.mp3", + tts_token=None, + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), ], ) async def test_announce( @@ -610,6 +647,51 @@ async def test_vad_sensitivity_entity_not_found( ), ), ), + ( + { + "start_message": "Hello", + "start_media_id": { + "media_content_id": "media-source://given", + "media_content_type": "provider", + }, + "preannounce": False, + }, + ( + "mock-conversation-id", + "Hello", + AssistSatelliteAnnouncement( + message="Hello", + media_id="https://www.home-assistant.io/resolved.mp3", + tts_token=None, + original_media_id="media-source://given", + media_id_source="media_id", + ), + ), + ), + ( + { + "start_media_id": { + "media_content_id": "http://example.com/given.mp3", + "media_content_type": "audio", + }, + "preannounce_media_id": { + "media_content_id": "http://example.com/preannounce.mp3", + "media_content_type": "audio", + }, + }, + ( + "mock-conversation-id", + None, + AssistSatelliteAnnouncement( + message="", + media_id="http://example.com/given.mp3", + tts_token=None, + original_media_id="http://example.com/given.mp3", + media_id_source="url", + preannounce_media_id="http://example.com/preannounce.mp3", + ), + ), + ), ], ) @pytest.mark.usefixtures("mock_chat_session_conversation_id") @@ -731,6 +813,10 @@ async def async_start_conversation(start_announcement): ), ( { + "question_media_id": { + "media_content_id": "media-source://tts/cloud?message=What+kind+of+music+would+you+like+to+listen+to%3F&language=en-US&gender=female", + "media_content_type": "provider", + }, "answers": [ { "id": "genre", diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 442cf8aea50b03..0494de1432cbf6 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -339,7 +339,7 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non ) assert pick_thread_progress_result["type"] is FlowResultType.ABORT - assert pick_thread_progress_result["reason"] == "unsupported_firmware" + assert pick_thread_progress_result["reason"] == "fw_install_failed" @pytest.mark.parametrize( diff --git a/tests/components/incomfort/test_config_flow.py b/tests/components/incomfort/test_config_flow.py index e3579182b3d789..2d9a8273ab6400 100644 --- a/tests/components/incomfort/test_config_flow.py +++ b/tests/components/incomfort/test_config_flow.py @@ -22,13 +22,13 @@ DHCP_SERVICE_INFO = DhcpServiceInfo( hostname="rfgateway", ip="192.168.1.12", - macaddress="0004A3DEADFF", + macaddress=dr.format_mac("00:04:A3:DE:AD:FF").replace(":", ""), ) DHCP_SERVICE_INFO_ALT = DhcpServiceInfo( hostname="rfgateway", ip="192.168.1.99", - macaddress="0004A3DEADFF", + macaddress=dr.format_mac("00:04:A3:DE:AD:FF").replace(":", ""), ) diff --git a/tests/components/ista_ecotrend/conftest.py b/tests/components/ista_ecotrend/conftest.py index 58977c99b594c9..7be1302aa4fca7 100644 --- a/tests/components/ista_ecotrend/conftest.py +++ b/tests/components/ista_ecotrend/conftest.py @@ -96,12 +96,16 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", @@ -115,16 +119,21 @@ def get_consumption_data(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "104", + "unit": "Einheiten", "additionalValue": "113,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,1", + "unit": "m³", "additionalValue": "61,1", + "additionalUnit": "kWh", }, { "type": "water", "value": "6,8", + "unit": "m³", }, ], }, @@ -200,16 +209,21 @@ def extend_statistics(obj_uuid: str | None = None) -> dict[str, Any]: { "type": "heating", "value": "9000", + "unit": "Einheiten", "additionalValue": "9000,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "9999,0", + "unit": "m³", "additionalValue": "90000,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "9000,0", + "unit": "m³", }, ], }, diff --git a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr index c9f5e72ae1f86d..7395e2f6dc6a42 100644 --- a/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_diagnostics.ambr @@ -12,13 +12,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -34,17 +38,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), @@ -103,13 +112,17 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }), dict({ @@ -125,17 +138,22 @@ }), 'readings': list([ dict({ + 'additionalUnit': 'kWh', 'additionalValue': '113,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '104', }), dict({ + 'additionalUnit': 'kWh', 'additionalValue': '61,1', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,1', }), dict({ 'type': 'water', + 'unit': 'm³', 'value': '6,8', }), ]), diff --git a/tests/components/ista_ecotrend/snapshots/test_util.ambr b/tests/components/ista_ecotrend/snapshots/test_util.ambr index 9069cb617e3a1f..8546b704d3de3c 100644 --- a/tests/components/ista_ecotrend/snapshots/test_util.ambr +++ b/tests/components/ista_ecotrend/snapshots/test_util.ambr @@ -97,12 +97,22 @@ # --- # name: test_get_statistics[water-energy] list([ + dict({ + 'date': datetime.datetime(2024, 4, 30, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 6.8, + }), + dict({ + 'date': datetime.datetime(2024, 5, 31, 0, 0, tzinfo=datetime.timezone.utc), + 'value': 5.0, + }), ]) # --- # name: test_get_values_by_type[heating] dict({ + 'additionalUnit': 'kWh', 'additionalValue': '38,0', 'type': 'heating', + 'unit': 'Einheiten', 'value': '35', }) # --- @@ -114,8 +124,10 @@ # --- # name: test_get_values_by_type[warmwater] dict({ + 'additionalUnit': 'kWh', 'additionalValue': '57,0', 'type': 'warmwater', + 'unit': 'm³', 'value': '1,0', }) # --- @@ -128,6 +140,7 @@ # name: test_get_values_by_type[water] dict({ 'type': 'water', + 'unit': 'm³', 'value': '5,0', }) # --- diff --git a/tests/components/ista_ecotrend/test_util.py b/tests/components/ista_ecotrend/test_util.py index f518a40b4b18eb..f6840dcd88b215 100644 --- a/tests/components/ista_ecotrend/test_util.py +++ b/tests/components/ista_ecotrend/test_util.py @@ -52,16 +52,21 @@ def test_get_values_by_type( { "type": "heating", "value": "35", + "unit": "Einheiten", "additionalValue": "38,0", + "additionalUnit": "kWh", }, { "type": "warmwater", "value": "1,0", + "unit": "m³", "additionalValue": "57,0", + "additionalUnit": "kWh", }, { "type": "water", "value": "5,0", + "unit": "m³", }, ], } diff --git a/tests/components/lg_thinq/test_config_flow.py b/tests/components/lg_thinq/test_config_flow.py index 7f601cd02c3050..a46162723f0c7d 100644 --- a/tests/components/lg_thinq/test_config_flow.py +++ b/tests/components/lg_thinq/test_config_flow.py @@ -7,6 +7,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_COUNTRY from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import MOCK_CONNECT_CLIENT_ID, MOCK_COUNTRY, MOCK_PAT @@ -16,7 +17,7 @@ DHCP_DISCOVERY = DhcpServiceInfo( ip="1.1.1.1", hostname="LG_Smart_Dryer2_open", - macaddress="34:E6:E6:11:22:33", + macaddress=dr.format_mac("34:E6:E6:11:22:33").replace(":", ""), ) diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py index cebb185bd08c60..d33fffe71521e8 100644 --- a/tests/components/ollama/test_conversation.py +++ b/tests/components/ollama/test_conversation.py @@ -206,7 +206,7 @@ async def test_template_variables( ), ], ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_call( mock_get_tools, hass: HomeAssistant, @@ -293,7 +293,7 @@ def completion_result(*args, messages, **kwargs): ) -@patch("homeassistant.components.ollama.conversation.llm.AssistAPI._async_get_tools") +@patch("homeassistant.components.ollama.entity.llm.AssistAPI._async_get_tools") async def test_function_exception( mock_get_tools, hass: HomeAssistant, diff --git a/tests/components/playstation_network/conftest.py b/tests/components/playstation_network/conftest.py index 821025dbb9c94b..431a30ba7f7f25 100644 --- a/tests/components/playstation_network/conftest.py +++ b/tests/components/playstation_network/conftest.py @@ -64,6 +64,7 @@ def mock_user() -> Generator[MagicMock]: "conceptIconUrl": "https://image.api.playstation.com/vulcan/ap/rnd/202211/2222/l8QTN7ThQK3lRBHhB3nX1s7h.png", } ], + "lastAvailableDate": "2025-06-30T01:42:15.391Z", } } diff --git a/tests/components/playstation_network/snapshots/test_diagnostics.ambr b/tests/components/playstation_network/snapshots/test_diagnostics.ambr index 405cee04559a86..6073b37863e1ed 100644 --- a/tests/components/playstation_network/snapshots/test_diagnostics.ambr +++ b/tests/components/playstation_network/snapshots/test_diagnostics.ambr @@ -26,6 +26,7 @@ 'titleName': 'STAR WARS Jedi: Survivor™', }), ]), + 'lastAvailableDate': '2025-06-30T01:42:15.391Z', 'primaryPlatformInfo': dict({ 'onlineStatus': 'online', 'platform': 'PS5', diff --git a/tests/components/playstation_network/snapshots/test_sensor.ambr b/tests/components/playstation_network/snapshots/test_sensor.ambr index 61030ee0a39136..233791c05bd7d3 100644 --- a/tests/components/playstation_network/snapshots/test_sensor.ambr +++ b/tests/components/playstation_network/snapshots/test_sensor.ambr @@ -97,6 +97,55 @@ 'state': '11754', }) # --- +# name: test_sensors[sensor.testuser_last_online-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.testuser_last_online', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last online', + 'platform': 'playstation_network', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'my-psn-id_last_online', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.testuser_last_online-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'testuser Last online', + }), + 'context': , + 'entity_id': 'sensor.testuser_last_online', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2025-06-30T01:42:15+00:00', + }) +# --- # name: test_sensors[sensor.testuser_next_level-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/playstation_network/test_init.py b/tests/components/playstation_network/test_init.py new file mode 100644 index 00000000000000..09fbe4b0de4a82 --- /dev/null +++ b/tests/components/playstation_network/test_init.py @@ -0,0 +1,109 @@ +"""Tests for PlayStation Network.""" + +from unittest.mock import MagicMock + +from psnawp_api.core import ( + PSNAWPAuthenticationError, + PSNAWPClientError, + PSNAWPNotFoundError, + PSNAWPServerError, +) +import pytest + +from homeassistant.components.playstation_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_config_entry_not_ready( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test config entry not ready.""" + + mock_psnawpapi.user.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test config entry auth failed setup error.""" + + mock_psnawpapi.user.side_effect = PSNAWPAuthenticationError + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id + + +@pytest.mark.parametrize( + "exception", [PSNAWPNotFoundError, PSNAWPServerError, PSNAWPClientError] +) +async def test_coordinator_update_data_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, + exception: Exception, +) -> None: + """Test coordinator data update failed.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = exception + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_update_auth_failed( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_psnawpapi: MagicMock, +) -> None: + """Test coordinator update auth failed setup error.""" + + mock_psnawpapi.user.return_value.get_presence.side_effect = ( + PSNAWPAuthenticationError + ) + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + flow = flows[0] + assert flow.get("step_id") == "reauth_confirm" + assert flow.get("handler") == DOMAIN + + assert "context" in flow + assert flow["context"].get("source") == SOURCE_REAUTH + assert flow["context"].get("entry_id") == config_entry.entry_id diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 2af90b9f7efa39..659effdda7ba63 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -117,6 +117,7 @@ async def test_reconfigure_flow_broadcast( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" assert mock_webhooks_config_entry.data[CONF_PLATFORM] == PLATFORM_BROADCAST + assert mock_webhooks_config_entry.data[CONF_PROXY_URL] == "https://test" async def test_reconfigure_flow_webhooks( diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index 5842d708f116b9..7d95aed7a5d247 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -12,10 +12,10 @@ from . import ( authorisation_response, + http_403_error, + http_404_error, http_429_error, setup_integration, - setup_integration_platform_not_ready, - setup_integration_read_only, ) from .const import MOCK_LOCK_ENTITY_ID @@ -38,11 +38,15 @@ async def test_wallbox_lock_class(hass: HomeAssistant, entry: MockConfigEntry) - ), patch( "homeassistant.components.wallbox.Wallbox.lockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 1}}} + ), ), patch( "homeassistant.components.wallbox.Wallbox.unlockCharger", - new=Mock(return_value={CHARGER_LOCKED_UNLOCKED_KEY: False}), + new=Mock( + return_value={"data": {"chargerData": {CHARGER_LOCKED_UNLOCKED_KEY: 0}}} + ), ), ): await hass.services.async_call( @@ -129,6 +133,52 @@ async def test_wallbox_lock_class_connection_error( new=Mock(side_effect=http_429_error), ), pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_LOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_403_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_403_error), + ), + pytest.raises(HomeAssistantError), + ): + await hass.services.async_call( + "lock", + SERVICE_UNLOCK, + { + ATTR_ENTITY_ID: MOCK_LOCK_ENTITY_ID, + }, + blocking=True, + ) + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.lockCharger", + new=Mock(side_effect=http_404_error), + ), + patch( + "homeassistant.components.wallbox.Wallbox.unlockCharger", + new=Mock(side_effect=http_404_error), + ), + pytest.raises(HomeAssistantError), ): await hass.services.async_call( "lock", @@ -138,27 +188,3 @@ async def test_wallbox_lock_class_connection_error( }, blocking=True, ) - - -async def test_wallbox_lock_class_authentication_error( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_read_only(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None - - -async def test_wallbox_lock_class_platform_not_ready( - hass: HomeAssistant, entry: MockConfigEntry -) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) - - state = hass.states.get(MOCK_LOCK_ENTITY_ID) - - assert state is None diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index c603ae24106294..8067917977d1d9 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -23,7 +23,6 @@ http_429_error, setup_integration, setup_integration_bidir, - setup_integration_platform_not_ready, ) from .const import ( MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, @@ -56,7 +55,9 @@ async def test_wallbox_number_class( ), patch( "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", - new=Mock(return_value={CHARGER_MAX_CHARGING_CURRENT_KEY: 20}), + new=Mock( + return_value={"data": {"chargerData": {"maxChargingCurrent": 20}}} + ), ), ): state = hass.states.get(MOCK_NUMBER_ENTITY_ID) @@ -259,19 +260,35 @@ async def test_wallbox_number_class_energy_price_auth_error( ) -async def test_wallbox_number_class_platform_not_ready( +async def test_wallbox_number_class_icp_energy( hass: HomeAssistant, entry: MockConfigEntry ) -> None: - """Test wallbox lock not loaded on authentication error.""" - - await setup_integration_platform_not_ready(hass, entry) + """Test wallbox sensor class.""" - state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + await setup_integration(hass, entry) - assert state is None + with ( + patch( + "homeassistant.components.wallbox.Wallbox.authenticate", + new=Mock(return_value=authorisation_response), + ), + patch( + "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + new=Mock(return_value={"icp_max_current": 20}), + ), + ): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_VALUE: 10, + }, + blocking=True, + ) -async def test_wallbox_number_class_icp_energy( +async def test_wallbox_number_class_icp_energy_auth_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox sensor class.""" @@ -285,8 +302,9 @@ async def test_wallbox_number_class_icp_energy( ), patch( "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", - new=Mock(return_value={CHARGER_MAX_ICP_CURRENT_KEY: 10}), + new=Mock(side_effect=http_403_error), ), + pytest.raises(InvalidAuth), ): await hass.services.async_call( NUMBER_DOMAIN, @@ -299,7 +317,7 @@ async def test_wallbox_number_class_icp_energy( ) -async def test_wallbox_number_class_icp_energy_auth_error( +async def test_wallbox_number_class_energy_auth_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: """Test wallbox sensor class.""" @@ -312,7 +330,7 @@ async def test_wallbox_number_class_icp_energy_auth_error( new=Mock(return_value=authorisation_response), ), patch( - "homeassistant.components.wallbox.Wallbox.setIcpMaxCurrent", + "homeassistant.components.wallbox.Wallbox.setMaxChargingCurrent", new=Mock(side_effect=http_403_error), ), pytest.raises(InvalidAuth), @@ -321,7 +339,7 @@ async def test_wallbox_number_class_icp_energy_auth_error( NUMBER_DOMAIN, SERVICE_SET_VALUE, { - ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ICP_CURRENT_ID, + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ID, ATTR_VALUE: 10, }, blocking=True, diff --git a/tests/components/wallbox/test_select.py b/tests/components/wallbox/test_select.py index f59a8367b41054..e46347bfa5a0ef 100644 --- a/tests/components/wallbox/test_select.py +++ b/tests/components/wallbox/test_select.py @@ -50,6 +50,13 @@ async def test_wallbox_select_solar_charging_class( ) -> None: """Test wallbox select class.""" + if mode == EcoSmartMode.OFF: + response = test_response + elif mode == EcoSmartMode.ECO_MODE: + response = test_response_eco_mode + elif mode == EcoSmartMode.FULL_SOLAR: + response = test_response_full_solar + with ( patch( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", @@ -59,6 +66,10 @@ async def test_wallbox_select_solar_charging_class( "homeassistant.components.wallbox.Wallbox.disableEcoSmart", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=response), + ), ): await setup_integration_select(hass, entry, response) @@ -110,6 +121,10 @@ async def test_wallbox_select_class_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( @@ -144,6 +159,10 @@ async def test_wallbox_select_too_many_requests_error( "homeassistant.components.wallbox.Wallbox.enableEcoSmart", new=Mock(side_effect=http_429_error), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response_eco_mode), + ), pytest.raises(HomeAssistantError), ): await hass.services.async_call( diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index eb983ca44ce34c..98b87828f7441a 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -10,7 +10,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from . import authorisation_response, http_404_error, http_429_error, setup_integration +from . import ( + authorisation_response, + http_404_error, + http_429_error, + setup_integration, + test_response, +) from .const import MOCK_SWITCH_ENTITY_ID from tests.common import MockConfigEntry @@ -40,6 +46,10 @@ async def test_wallbox_switch_class( "homeassistant.components.wallbox.Wallbox.resumeChargingSession", new=Mock(return_value={CHARGER_STATUS_ID_KEY: 193}), ), + patch( + "homeassistant.components.wallbox.Wallbox.getChargerStatus", + new=Mock(return_value=test_response), + ), ): await hass.services.async_call( "switch", diff --git a/tests/components/weatherflow_cloud/test_sensor.py b/tests/components/weatherflow_cloud/test_sensor.py index 191f720527f9e5..dce2b7f8f2ef8c 100644 --- a/tests/components/weatherflow_cloud/test_sensor.py +++ b/tests/components/weatherflow_cloud/test_sensor.py @@ -26,7 +26,7 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, - load_fixture, + async_load_fixture, snapshot_platform, ) @@ -61,7 +61,7 @@ async def test_all_entities_with_lightning_error( """Test all entities.""" get_observation_response_data = ObservationStationREST.from_json( - load_fixture("station_observation_error.json", DOMAIN) + await async_load_fixture(hass, "station_observation_error.json", DOMAIN) ) with patch(