Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed68a21afd | |||
| 612cc91423 | |||
| 170989ef30 | |||
| 4aebf41c59 | |||
| abbaaf4ff5 |
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.29.2
|
||||
uses: github/codeql-action/init@v3.29.1
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.29.2
|
||||
uses: github/codeql-action/analyze@v3.29.1
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -61,8 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -71,13 +69,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -147,34 +138,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -75,7 +75,6 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,17 +1,69 @@
|
||||
"""Conversation support for Anthropic."""
|
||||
|
||||
from typing import Literal
|
||||
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 homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
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
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -30,10 +82,253 @@ 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,
|
||||
AnthropicBaseLLMEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""Anthropic conversation agent."""
|
||||
|
||||
@@ -41,7 +336,17 @@ class AnthropicConversationEntity(
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
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,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -52,6 +357,13 @@ class AnthropicConversationEntity(
|
||||
"""Return a list of supported languages."""
|
||||
return MATCH_ALL
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
@@ -82,3 +394,77 @@ class AnthropicConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
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:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
"""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
|
||||
@@ -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"): _media_id_validator,
|
||||
vol.Optional("media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
@@ -81,16 +81,15 @@ 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"): _media_id_validator,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
@@ -136,9 +135,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): _media_id_validator,
|
||||
vol.Optional("question_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("answers"): [
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -205,20 +204,3 @@ 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"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,9 +14,7 @@ announce:
|
||||
media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -25,9 +23,7 @@ announce:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
start_conversation:
|
||||
target:
|
||||
entity:
|
||||
@@ -44,9 +40,7 @@ start_conversation:
|
||||
start_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
extra_system_prompt:
|
||||
required: false
|
||||
selector:
|
||||
@@ -59,9 +53,7 @@ start_conversation:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
ask_question:
|
||||
fields:
|
||||
entity_id:
|
||||
@@ -80,9 +72,7 @@ ask_question:
|
||||
question_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -91,9 +81,7 @@ ask_question:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
answers:
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
from .services import async_setup_services
|
||||
from .services import setup_services
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up bosch alarm services."""
|
||||
async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -66,8 +66,7 @@ async def async_set_panel_date(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the bosch alarm integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -7,15 +7,18 @@ import numpy as np
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_MAXIMUM,
|
||||
CONF_MINIMUM,
|
||||
CONF_NAME,
|
||||
CONF_SOURCE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -32,6 +35,7 @@ from .const import (
|
||||
DEFAULT_DEGREE,
|
||||
DEFAULT_PRECISION,
|
||||
DOMAIN,
|
||||
PLATFORMS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -77,59 +81,104 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def create_compensation_data(
|
||||
hass: HomeAssistant, compensation: str, conf: ConfigType, should_raise: bool = False
|
||||
) -> None:
|
||||
"""Create compensation data."""
|
||||
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
|
||||
|
||||
degree = conf[CONF_DEGREE]
|
||||
|
||||
initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS]
|
||||
sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0))
|
||||
|
||||
# get x values and y values from the x,y point pairs
|
||||
x_values, y_values = zip(*initial_coefficients, strict=False)
|
||||
|
||||
# try to get valid coefficients for a polynomial
|
||||
coefficients = None
|
||||
with np.errstate(all="raise"):
|
||||
try:
|
||||
coefficients = np.polyfit(x_values, y_values, degree)
|
||||
except FloatingPointError as error:
|
||||
_LOGGER.error(
|
||||
"Setup of %s encountered an error, %s",
|
||||
compensation,
|
||||
error,
|
||||
)
|
||||
if should_raise:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_error",
|
||||
translation_placeholders={
|
||||
"title": conf[CONF_NAME],
|
||||
"error": str(error),
|
||||
},
|
||||
) from error
|
||||
|
||||
if coefficients is not None:
|
||||
data = {
|
||||
k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS]
|
||||
}
|
||||
data[CONF_POLYNOMIAL] = np.poly1d(coefficients)
|
||||
|
||||
if data[CONF_LOWER_LIMIT]:
|
||||
data[CONF_MINIMUM] = sorted_coefficients[0]
|
||||
else:
|
||||
data[CONF_MINIMUM] = None
|
||||
|
||||
if data[CONF_UPPER_LIMIT]:
|
||||
data[CONF_MAXIMUM] = sorted_coefficients[-1]
|
||||
else:
|
||||
data[CONF_MAXIMUM] = None
|
||||
|
||||
hass.data[DATA_COMPENSATION][compensation] = data
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Compensation sensor."""
|
||||
hass.data[DATA_COMPENSATION] = {}
|
||||
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
for compensation, conf in config[DOMAIN].items():
|
||||
_LOGGER.debug("Setup %s.%s", DOMAIN, compensation)
|
||||
|
||||
degree = conf[CONF_DEGREE]
|
||||
|
||||
initial_coefficients: list[tuple[float, float]] = conf[CONF_DATAPOINTS]
|
||||
sorted_coefficients = sorted(initial_coefficients, key=itemgetter(0))
|
||||
|
||||
# get x values and y values from the x,y point pairs
|
||||
x_values, y_values = zip(*initial_coefficients, strict=False)
|
||||
|
||||
# try to get valid coefficients for a polynomial
|
||||
coefficients = None
|
||||
with np.errstate(all="raise"):
|
||||
try:
|
||||
coefficients = np.polyfit(x_values, y_values, degree)
|
||||
except FloatingPointError as error:
|
||||
_LOGGER.error(
|
||||
"Setup of %s encountered an error, %s",
|
||||
compensation,
|
||||
error,
|
||||
)
|
||||
|
||||
if coefficients is not None:
|
||||
data = {
|
||||
k: v for k, v in conf.items() if k not in [CONF_DEGREE, CONF_DATAPOINTS]
|
||||
}
|
||||
data[CONF_POLYNOMIAL] = np.poly1d(coefficients)
|
||||
|
||||
if data[CONF_LOWER_LIMIT]:
|
||||
data[CONF_MINIMUM] = sorted_coefficients[0]
|
||||
else:
|
||||
data[CONF_MINIMUM] = None
|
||||
|
||||
if data[CONF_UPPER_LIMIT]:
|
||||
data[CONF_MAXIMUM] = sorted_coefficients[-1]
|
||||
else:
|
||||
data[CONF_MAXIMUM] = None
|
||||
|
||||
hass.data[DATA_COMPENSATION][compensation] = data
|
||||
|
||||
hass.async_create_task(
|
||||
async_load_platform(
|
||||
hass,
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
{CONF_COMPENSATION: compensation},
|
||||
config,
|
||||
)
|
||||
await create_compensation_data(hass, compensation, conf)
|
||||
hass.async_create_task(
|
||||
async_load_platform(
|
||||
hass,
|
||||
SENSOR_DOMAIN,
|
||||
DOMAIN,
|
||||
{CONF_COMPENSATION: compensation},
|
||||
config,
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Compensation from a config entry."""
|
||||
config = dict(entry.options)
|
||||
data_points = config[CONF_DATAPOINTS]
|
||||
new_data_points = []
|
||||
for data_point in data_points:
|
||||
values = data_point.split(",", maxsplit=1)
|
||||
new_data_points.append([float(values[0]), float(values[1])])
|
||||
config[CONF_DATAPOINTS] = new_data_points
|
||||
|
||||
await create_compensation_data(hass, entry.entry_id, config, True)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload Compensation config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Config flow for statistics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, cast
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_NAME,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
)
|
||||
from homeassistant.helpers.schema_config_entry_flow import (
|
||||
SchemaCommonFlowHandler,
|
||||
SchemaConfigFlowHandler,
|
||||
SchemaFlowError,
|
||||
SchemaFlowFormStep,
|
||||
)
|
||||
from homeassistant.helpers.selector import (
|
||||
AttributeSelector,
|
||||
AttributeSelectorConfig,
|
||||
BooleanSelector,
|
||||
EntitySelector,
|
||||
NumberSelector,
|
||||
NumberSelectorConfig,
|
||||
NumberSelectorMode,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
TextSelector,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_DATAPOINTS,
|
||||
CONF_DEGREE,
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_PRECISION,
|
||||
CONF_UPPER_LIMIT,
|
||||
DEFAULT_DEGREE,
|
||||
DEFAULT_NAME,
|
||||
DEFAULT_PRECISION,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
|
||||
async def get_options_schema(handler: SchemaCommonFlowHandler) -> vol.Schema:
|
||||
"""Get options schema."""
|
||||
entity_id = handler.options[CONF_ENTITY_ID]
|
||||
|
||||
return vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DATAPOINTS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[],
|
||||
multiple=True,
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_ATTRIBUTE): AttributeSelector(
|
||||
AttributeSelectorConfig(entity_id=entity_id)
|
||||
),
|
||||
vol.Optional(CONF_UPPER_LIMIT, default=False): BooleanSelector(),
|
||||
vol.Optional(CONF_LOWER_LIMIT, default=False): BooleanSelector(),
|
||||
vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): NumberSelector(
|
||||
NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Optional(CONF_DEGREE, default=DEFAULT_DEGREE): NumberSelector(
|
||||
NumberSelectorConfig(min=0, max=7, step=1, mode=NumberSelectorMode.BOX)
|
||||
),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_data_points(check_data_points: list[str]) -> bool:
|
||||
"""Validate data points."""
|
||||
result = False
|
||||
for data_point in check_data_points:
|
||||
if not data_point.find(",") > 0:
|
||||
return False
|
||||
values = data_point.split(",", maxsplit=1)
|
||||
for value in values:
|
||||
try:
|
||||
float(value)
|
||||
except ValueError:
|
||||
return False
|
||||
result = True
|
||||
return result
|
||||
|
||||
|
||||
async def validate_options(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Validate options selected."""
|
||||
|
||||
user_input[CONF_PRECISION] = int(user_input[CONF_PRECISION])
|
||||
user_input[CONF_DEGREE] = int(user_input[CONF_DEGREE])
|
||||
|
||||
if not _is_valid_data_points(user_input[CONF_DATAPOINTS]):
|
||||
raise SchemaFlowError("incorrect_datapoints")
|
||||
|
||||
if len(user_input[CONF_DATAPOINTS]) <= user_input[CONF_DEGREE]:
|
||||
raise SchemaFlowError("not_enough_datapoints")
|
||||
|
||||
handler.parent_handler._async_abort_entries_match({**handler.options, **user_input}) # noqa: SLF001
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
DATA_SCHEMA_SETUP = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(),
|
||||
vol.Required(CONF_ENTITY_ID): EntitySelector(),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_FLOW = {
|
||||
"user": SchemaFlowFormStep(
|
||||
schema=DATA_SCHEMA_SETUP,
|
||||
next_step="options",
|
||||
),
|
||||
"options": SchemaFlowFormStep(
|
||||
schema=get_options_schema,
|
||||
validate_user_input=validate_options,
|
||||
),
|
||||
}
|
||||
OPTIONS_FLOW = {
|
||||
"init": SchemaFlowFormStep(
|
||||
get_options_schema,
|
||||
validate_user_input=validate_options,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class CompensationConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
|
||||
"""Handle a config flow for Compensation."""
|
||||
|
||||
config_flow = CONFIG_FLOW
|
||||
options_flow = OPTIONS_FLOW
|
||||
|
||||
def async_config_entry_title(self, options: Mapping[str, Any]) -> str:
|
||||
"""Return config entry title."""
|
||||
return cast(str, options[CONF_NAME])
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Compensation constants."""
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "compensation"
|
||||
PLATFORMS = [Platform.SENSOR]
|
||||
|
||||
SENSOR = "compensation"
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"domain": "compensation",
|
||||
"name": "Compensation",
|
||||
"codeowners": ["@Petro31"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/compensation",
|
||||
"integration_type": "helper",
|
||||
"iot_class": "calculated",
|
||||
"quality_scale": "legacy",
|
||||
"requirements": ["numpy==2.3.0"]
|
||||
|
||||
@@ -8,9 +8,11 @@ from typing import Any
|
||||
import numpy as np
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_ATTRIBUTE,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_MAXIMUM,
|
||||
CONF_MINIMUM,
|
||||
CONF_SOURCE,
|
||||
@@ -80,6 +82,36 @@ async def async_setup_platform(
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Compensation sensor entry."""
|
||||
compensation = entry.entry_id
|
||||
conf: dict[str, Any] = hass.data[DATA_COMPENSATION][compensation]
|
||||
|
||||
source: str = conf[CONF_ENTITY_ID]
|
||||
attribute: str | None = conf.get(CONF_ATTRIBUTE)
|
||||
name = entry.title
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
CompensationSensor(
|
||||
entry.entry_id,
|
||||
name,
|
||||
source,
|
||||
attribute,
|
||||
conf[CONF_PRECISION],
|
||||
conf[CONF_POLYNOMIAL],
|
||||
conf.get(CONF_UNIT_OF_MEASUREMENT),
|
||||
conf[CONF_MINIMUM],
|
||||
conf[CONF_MAXIMUM],
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CompensationSensor(SensorEntity):
|
||||
"""Representation of a Compensation sensor."""
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"incorrect_datapoints": "Datapoints needs to be provided in the right format, ex. '1.0, 0.0'.",
|
||||
"not_enough_datapoints": "The number of datapoints needs to be more than the configured degree."
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Add a compensation sensor",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"entity_id": "Entity"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "Name for the created entity.",
|
||||
"entity_id": "Entity to use as source."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"description": "Read the documention for further details on how to configure the statistics sensor using these options.",
|
||||
"data": {
|
||||
"data_points": "Data points",
|
||||
"attribute": "Attribute",
|
||||
"upper_limit": "Upper limit",
|
||||
"lower_limit": "Lower limit",
|
||||
"precision": "Precision",
|
||||
"degree": "Degree",
|
||||
"unit_of_measurement": "Unit of measurement"
|
||||
},
|
||||
"data_description": {
|
||||
"data_points": "The collection of data point conversions with the format 'uncompensated_value, compensated_value', ex. '1.0, 0.0'",
|
||||
"attribute": "Attribute from the source to monitor/compensate.",
|
||||
"upper_limit": "Enables an upper limit for the sensor. The upper limit is defined by the data collections (data_points) greatest uncompensated value.",
|
||||
"lower_limit": "Enables a lower limit for the sensor. The lower limit is defined by the data collections (data_points) lowest uncompensated value.",
|
||||
"precision": "Defines the precision of the calculated values, through the argument of round().",
|
||||
"degree": "The degree of a polynomial.",
|
||||
"unit_of_measurement": "Defines the units of measurement of the sensor, if any."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
},
|
||||
"error": {
|
||||
"incorrect_datapoints": "[%key:component::compensation::config::error::incorrect_datapoints%]",
|
||||
"not_enough_datapoints": "[%key:component::compensation::config::error::not_enough_datapoints%]"
|
||||
},
|
||||
"step": {
|
||||
"init": {
|
||||
"description": "[%key:component::compensation::config::step::options::description%]",
|
||||
"data": {
|
||||
"data_points": "[%key:component::compensation::config::step::options::data::data_points%]",
|
||||
"attribute": "[%key:component::compensation::config::step::options::data::attribute%]",
|
||||
"upper_limit": "[%key:component::compensation::config::step::options::data::upper_limit%]",
|
||||
"lower_limit": "[%key:component::compensation::config::step::options::data::lower_limit%]",
|
||||
"precision": "[%key:component::compensation::config::step::options::data::precision%]",
|
||||
"degree": "[%key:component::compensation::config::step::options::data::degree%]",
|
||||
"unit_of_measurement": "[%key:component::compensation::config::step::options::data::unit_of_measurement%]"
|
||||
},
|
||||
"data_description": {
|
||||
"data_points": "[%key:component::compensation::config::step::options::data_description::data_points%]",
|
||||
"attribute": "[%key:component::compensation::config::step::options::data_description::attribute%]",
|
||||
"upper_limit": "[%key:component::compensation::config::step::options::data_description::upper_limit%]",
|
||||
"lower_limit": "[%key:component::compensation::config::step::options::data_description::lower_limit%]",
|
||||
"precision": "[%key:component::compensation::config::step::options::data_description::precision%]",
|
||||
"degree": "[%key:component::compensation::config::step::options::data_description::degree%]",
|
||||
"unit_of_measurement": "[%key:component::compensation::config::step::options::data_description::unit_of_measurement%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"setup_error": {
|
||||
"message": "Setup of {title} could not be setup due to {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,8 @@ from pycoolmasternet_async import CoolMasterNet
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_SWING_SUPPORT, DOMAIN
|
||||
from .const import CONF_SWING_SUPPORT
|
||||
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
|
||||
@@ -49,14 +48,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
|
||||
"""Unload a Coolmaster config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CoolmasterConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
return not device_entry.identifiers.intersection(
|
||||
(DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data
|
||||
)
|
||||
|
||||
@@ -92,7 +92,7 @@ SENSORS: list[DROPSensorEntityDescription] = [
|
||||
native_unit_of_measurement=UnitOfVolume.GALLONS,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda device: device.drop_api.water_used_today(),
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
DROPSensorEntityDescription(
|
||||
key=AVERAGE_WATER_USED,
|
||||
|
||||
@@ -12,7 +12,7 @@ from .bridge import DynaliteBridge
|
||||
from .const import DOMAIN, LOGGER, PLATFORMS
|
||||
from .convert_config import convert_config
|
||||
from .panel import async_register_dynalite_frontend
|
||||
from .services import async_setup_services
|
||||
from .services import setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -21,7 +21,7 @@ type DynaliteConfigEntry = ConfigEntry[DynaliteBridge]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Dynalite platform."""
|
||||
async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
|
||||
await async_register_dynalite_frontend(hass)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None:
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Dynalite platform."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"full": "[%key:common::state::full%]"
|
||||
"full": "Full"
|
||||
}
|
||||
},
|
||||
"acb_available_energy": {
|
||||
|
||||
@@ -81,7 +81,6 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
# if the string is empty
|
||||
if unit_of_measurement := static_info.unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
self._attr_suggested_display_precision = static_info.accuracy_decimals
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, static_info.device_class
|
||||
)
|
||||
@@ -98,7 +97,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
self._attr_state_class = _STATE_CLASSES.from_esphome(state_class)
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | int | float | None:
|
||||
def native_value(self) -> datetime | str | None:
|
||||
"""Return the state of the entity."""
|
||||
if not self._has_state or (state := self._state).missing_state:
|
||||
return None
|
||||
@@ -107,7 +106,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
return None
|
||||
if self.device_class is SensorDeviceClass.TIMESTAMP:
|
||||
return dt_util.utc_from_timestamp(state_float)
|
||||
return state_float
|
||||
return f"{state_float:.{self._static_info.accuracy_decimals}f}"
|
||||
|
||||
|
||||
class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity):
|
||||
|
||||
@@ -308,50 +308,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_TITLE,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Add TTS subentry which was missing in 2025.7.0b0
|
||||
if not any(
|
||||
subentry.subentry_type == "tts" for subentry in entry.subentries.values()
|
||||
):
|
||||
hass.config_entries.async_add_subentry(
|
||||
entry,
|
||||
ConfigSubentry(
|
||||
data=MappingProxyType(RECOMMENDED_TTS_OPTIONS),
|
||||
subentry_type="tts",
|
||||
title=DEFAULT_TTS_NAME,
|
||||
unique_id=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -92,7 +92,6 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Google Generative AI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_api(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -27,7 +27,7 @@ from .const import (
|
||||
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
|
||||
)
|
||||
from .coordinator import GuardianDataUpdateCoordinator
|
||||
from .services import async_setup_services
|
||||
from .services import 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."""
|
||||
async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -122,9 +122,8 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None:
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the guardian services."""
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the Renault services."""
|
||||
for service_name, schema, method in (
|
||||
(
|
||||
SERVICE_NAME_PAIR_SENSOR,
|
||||
|
||||
@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
|
||||
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 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HEOS component."""
|
||||
async_setup_services(hass)
|
||||
services.register(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
@@ -44,8 +44,7 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
|
||||
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register(hass: HomeAssistant) -> None:
|
||||
"""Register HEOS services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
|
||||
from .services import async_setup_services
|
||||
from .services import register_actions
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +43,7 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Home Connect component."""
|
||||
async_setup_services(hass)
|
||||
register_actions(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
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,8 +522,7 @@ async def async_service_start_program(call: ServiceCall) -> None:
|
||||
await _async_service_program(call, True)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_actions(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
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
|
||||
|
||||
@@ -68,7 +67,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
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."""
|
||||
@@ -154,12 +152,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
assert self._device is not None
|
||||
|
||||
if not self.firmware_install_task:
|
||||
# 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.
|
||||
# We 100% need to install new firmware only if the wrong firmware is
|
||||
# currently installed
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
@@ -173,7 +167,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing):
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch firmware update manifest", exc_info=True
|
||||
)
|
||||
@@ -185,9 +179,13 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
raise AbortFlow(
|
||||
"fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
@@ -207,7 +205,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError):
|
||||
except (TimeoutError, ClientError, ValueError) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
@@ -218,9 +216,13 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
# Otherwise, fail
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
raise AbortFlow(
|
||||
"fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
async_flash_silabs_firmware(
|
||||
@@ -247,40 +249,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
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:
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
"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_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
|
||||
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again."
|
||||
},
|
||||
"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."
|
||||
|
||||
@@ -93,8 +93,7 @@
|
||||
"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_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
@@ -148,8 +147,7 @@
|
||||
"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_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
|
||||
@@ -118,8 +118,7 @@
|
||||
"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_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
"""The constants for the Husqvarna Automower integration."""
|
||||
|
||||
from aioautomower.model import MowerStates
|
||||
|
||||
DOMAIN = "husqvarna_automower"
|
||||
EXECUTION_TIME_DELAY = 5
|
||||
NAME = "Husqvarna Automower"
|
||||
OAUTH2_AUTHORIZE = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/authorize"
|
||||
OAUTH2_TOKEN = "https://api.authentication.husqvarnagroup.dev/v1/oauth2/token"
|
||||
|
||||
ERROR_STATES = [
|
||||
MowerStates.ERROR_AT_POWER_UP,
|
||||
MowerStates.ERROR,
|
||||
MowerStates.FATAL_ERROR,
|
||||
MowerStates.OFF,
|
||||
MowerStates.STOPPED,
|
||||
MowerStates.WAIT_POWER_UP,
|
||||
MowerStates.WAIT_UPDATING,
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import DOMAIN, ERROR_STATES
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerAvailableEntity, handle_sending_exception
|
||||
|
||||
@@ -108,28 +108,18 @@ class AutomowerLawnMowerEntity(AutomowerAvailableEntity, LawnMowerEntity):
|
||||
def activity(self) -> LawnMowerActivity:
|
||||
"""Return the state of the mower."""
|
||||
mower_attributes = self.mower_attributes
|
||||
if mower_attributes.mower.state in ERROR_STATES:
|
||||
return LawnMowerActivity.ERROR
|
||||
if mower_attributes.mower.state in PAUSED_STATES:
|
||||
return LawnMowerActivity.PAUSED
|
||||
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
|
||||
return LawnMowerActivity.RETURNING
|
||||
if (
|
||||
mower_attributes.mower.state is MowerStates.RESTRICTED
|
||||
or mower_attributes.mower.activity in DOCKED_ACTIVITIES
|
||||
if (mower_attributes.mower.state == "RESTRICTED") or (
|
||||
mower_attributes.mower.activity in DOCKED_ACTIVITIES
|
||||
):
|
||||
return LawnMowerActivity.DOCKED
|
||||
if mower_attributes.mower.state in MowerStates.IN_OPERATION:
|
||||
if mower_attributes.mower.activity == MowerActivities.GOING_HOME:
|
||||
return LawnMowerActivity.RETURNING
|
||||
return LawnMowerActivity.MOWING
|
||||
return LawnMowerActivity.ERROR
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return the available attribute of the entity."""
|
||||
return (
|
||||
super().available and self.mower_attributes.mower.state != MowerStates.OFF
|
||||
)
|
||||
|
||||
@property
|
||||
def work_areas(self) -> dict[int, WorkArea] | None:
|
||||
"""Return the work areas of the mower."""
|
||||
|
||||
@@ -7,7 +7,13 @@ import logging
|
||||
from operator import attrgetter
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aioautomower.model import MowerAttributes, MowerModes, RestrictedReasons, WorkArea
|
||||
from aioautomower.model import (
|
||||
MowerAttributes,
|
||||
MowerModes,
|
||||
MowerStates,
|
||||
RestrictedReasons,
|
||||
WorkArea,
|
||||
)
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -21,7 +27,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import ERROR_STATES
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import (
|
||||
AutomowerBaseEntity,
|
||||
@@ -161,6 +166,15 @@ ERROR_KEYS = [
|
||||
"zone_generator_problem",
|
||||
]
|
||||
|
||||
ERROR_STATES = [
|
||||
MowerStates.ERROR_AT_POWER_UP,
|
||||
MowerStates.ERROR,
|
||||
MowerStates.FATAL_ERROR,
|
||||
MowerStates.OFF,
|
||||
MowerStates.STOPPED,
|
||||
MowerStates.WAIT_POWER_UP,
|
||||
MowerStates.WAIT_UPDATING,
|
||||
]
|
||||
|
||||
ERROR_KEY_LIST = list(
|
||||
dict.fromkeys(ERROR_KEYS + [state.lower() for state in ERROR_STATES])
|
||||
|
||||
@@ -108,22 +108,22 @@ def get_statistics(
|
||||
if monthly_consumptions := get_consumptions(data, value_type):
|
||||
return [
|
||||
{
|
||||
"value": as_number(value),
|
||||
"value": as_number(
|
||||
get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
).get(
|
||||
"additionalValue"
|
||||
if value_type == IstaValueType.ENERGY
|
||||
else "value"
|
||||
)
|
||||
),
|
||||
"date": consumptions["date"],
|
||||
}
|
||||
for consumptions in monthly_consumptions
|
||||
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"
|
||||
)
|
||||
)
|
||||
if get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
).get("additionalValue" if value_type == IstaValueType.ENERGY else "value")
|
||||
]
|
||||
return None
|
||||
|
||||
@@ -91,7 +91,7 @@ from .schema import (
|
||||
TimeSchema,
|
||||
WeatherSchema,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
from .services import register_knx_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)
|
||||
|
||||
async_setup_services(hass)
|
||||
register_knx_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_knx_services(hass: HomeAssistant) -> None:
|
||||
"""Register KNX integration services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -780,10 +780,10 @@
|
||||
"battery_level": {
|
||||
"name": "Battery",
|
||||
"state": {
|
||||
"high": "[%key:common::state::full%]",
|
||||
"high": "Full",
|
||||
"mid": "[%key:common::state::medium%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"warning": "[%key:common::state::empty%]"
|
||||
"warning": "Empty"
|
||||
}
|
||||
},
|
||||
"relative_to_start": {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"motor_fault_short": "Motor shorted",
|
||||
"motor_ot_amps": "Motor overtorqued",
|
||||
"motor_disconnected": "Motor disconnected",
|
||||
"empty": "[%key:common::state::empty%]"
|
||||
"empty": "Empty"
|
||||
}
|
||||
},
|
||||
"last_seen": {
|
||||
|
||||
@@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
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 async_setup_services
|
||||
from .services import register_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
config[CONF_COMMANDS],
|
||||
)
|
||||
|
||||
async_setup_services(hass)
|
||||
register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
@@ -50,8 +50,7 @@ async def _handle_send_message(call: ServiceCall) -> None:
|
||||
await matrix_bot.handle_send_message(call)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Matrix bot component."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -172,7 +172,7 @@ async def async_modbus_setup(
|
||||
|
||||
async def async_write_register(service: ServiceCall) -> None:
|
||||
"""Write Modbus registers."""
|
||||
slave = 1
|
||||
slave = 0
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
|
||||
@@ -195,7 +195,7 @@ async def async_modbus_setup(
|
||||
|
||||
async def async_write_coil(service: ServiceCall) -> None:
|
||||
"""Write Modbus coil."""
|
||||
slave = 1
|
||||
slave = 0
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
if ATTR_SLAVE in service.data:
|
||||
|
||||
@@ -2771,10 +2771,11 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
reconfig=True,
|
||||
)
|
||||
if user_input is not None:
|
||||
new_device_data: dict[str, Any] = user_input.copy()
|
||||
_, errors = validate_user_input(user_input, MQTT_DEVICE_PLATFORM_FIELDS)
|
||||
if "advanced_settings" in new_device_data:
|
||||
new_device_data |= new_device_data.pop("advanced_settings")
|
||||
new_device_data, errors = validate_user_input(
|
||||
user_input, MQTT_DEVICE_PLATFORM_FIELDS
|
||||
)
|
||||
if "mqtt_settings" in user_input:
|
||||
new_device_data["mqtt_settings"] = user_input["mqtt_settings"]
|
||||
if not errors:
|
||||
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
|
||||
@@ -69,9 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bo
|
||||
|
||||
entry.runtime_data = client
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -82,11 +79,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -156,34 +148,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -73,7 +73,6 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ollama."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
|
||||
@@ -2,18 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
import ollama
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import OllamaBaseLLMEntity
|
||||
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__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -32,10 +55,129 @@ 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,
|
||||
OllamaBaseLLMEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""Ollama conversation agent."""
|
||||
|
||||
@@ -43,7 +185,17 @@ class OllamaConversationEntity(
|
||||
|
||||
def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
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,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -56,6 +208,9 @@ class OllamaConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -99,3 +254,93 @@ class OllamaConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
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:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""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:],
|
||||
]
|
||||
@@ -284,8 +284,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -294,11 +292,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -368,34 +361,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -99,7 +99,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenAI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,19 +1,73 @@
|
||||
"""Conversation support for OpenAI."""
|
||||
|
||||
from typing import Literal
|
||||
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 assist_pipeline, conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import OpenAIBaseLLMEntity
|
||||
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,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -32,10 +86,152 @@ 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,
|
||||
OpenAIBaseLLMEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""OpenAI conversation agent."""
|
||||
|
||||
@@ -43,7 +239,17 @@ class OpenAIConversationEntity(
|
||||
|
||||
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
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,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -61,6 +267,9 @@ class OpenAIConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -95,3 +304,95 @@ class OpenAIConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
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:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
"""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
|
||||
@@ -21,7 +21,6 @@ from homeassistant.components.climate import (
|
||||
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
|
||||
|
||||
@@ -31,7 +30,6 @@ from .const import (
|
||||
CONF_SET_PRECISION,
|
||||
DATA_GATEWAYS,
|
||||
DATA_OPENTHERM_GW,
|
||||
DOMAIN,
|
||||
THERMOSTAT_DEVICE_DESCRIPTION,
|
||||
OpenThermDataSource,
|
||||
)
|
||||
@@ -77,7 +75,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes = [HVACMode.HEAT]
|
||||
_attr_hvac_modes = []
|
||||
_attr_name = None
|
||||
_attr_preset_modes = []
|
||||
_attr_min_temp = 1
|
||||
@@ -131,11 +129,9 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
|
||||
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
|
||||
|
||||
@@ -186,13 +182,6 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
|
||||
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")
|
||||
|
||||
@@ -355,9 +355,6 @@
|
||||
}
|
||||
},
|
||||
"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!"
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"sensor": {
|
||||
"battery": {
|
||||
"state": {
|
||||
"full": "[%key:common::state::full%]",
|
||||
"full": "Full",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
|
||||
@@ -7,13 +7,13 @@ import logging
|
||||
|
||||
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, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -28,6 +28,7 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData
|
||||
"""Data update coordinator for PSN."""
|
||||
|
||||
config_entry: PlaystationNetworkConfigEntry
|
||||
user: User
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -50,17 +51,12 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData
|
||||
"""Set up the coordinator."""
|
||||
|
||||
try:
|
||||
await self.psn.get_user()
|
||||
self.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."""
|
||||
@@ -71,7 +67,7 @@ class PlaystationNetworkCoordinator(DataUpdateCoordinator[PlaystationNetworkData
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="not_ready",
|
||||
) from error
|
||||
except (PSNAWPServerError, PSNAWPClientError) as error:
|
||||
except PSNAWPServerError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
|
||||
@@ -26,9 +26,6 @@
|
||||
},
|
||||
"online_id": {
|
||||
"default": "mdi:account"
|
||||
},
|
||||
"last_online": {
|
||||
"default": "mdi:account-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,21 +60,6 @@
|
||||
},
|
||||
{
|
||||
"macaddress": "D44B5E*"
|
||||
},
|
||||
{
|
||||
"macaddress": "F8D0AC*"
|
||||
},
|
||||
{
|
||||
"macaddress": "E86E3A*"
|
||||
},
|
||||
{
|
||||
"macaddress": "FC0FE6*"
|
||||
},
|
||||
{
|
||||
"macaddress": "9C37CB*"
|
||||
},
|
||||
{
|
||||
"macaddress": "84E657*"
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/playstation_network",
|
||||
|
||||
@@ -4,22 +4,16 @@ from __future__ import annotations
|
||||
|
||||
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 (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.components.sensor import 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 (
|
||||
@@ -35,7 +29,7 @@ PARALLEL_UPDATES = 0
|
||||
class PlaystationNetworkSensorEntityDescription(SensorEntityDescription):
|
||||
"""PlayStation Network sensor description."""
|
||||
|
||||
value_fn: Callable[[PlaystationNetworkData], StateType | datetime]
|
||||
value_fn: Callable[[PlaystationNetworkData], StateType]
|
||||
entity_picture: str | None = None
|
||||
|
||||
|
||||
@@ -49,7 +43,6 @@ 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, ...] = (
|
||||
@@ -109,16 +102,6 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -164,7 +147,7 @@ class PlaystationNetworkSensorEntity(
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
|
||||
return self.entity_description.value_fn(self.coordinator.data)
|
||||
|
||||
@@ -78,9 +78,6 @@
|
||||
},
|
||||
"online_id": {
|
||||
"name": "Online-ID"
|
||||
},
|
||||
"last_online": {
|
||||
"name": "Last online"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
|
||||
from .renault_hub import RenaultHub
|
||||
from .services import async_setup_services
|
||||
from .services import setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
type RenaultConfigEntry = ConfigEntry[RenaultHub]
|
||||
@@ -20,7 +20,7 @@ type RenaultConfigEntry = ConfigEntry[RenaultHub]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Renault component."""
|
||||
async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
@@ -191,8 +191,7 @@ def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the Renault services."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -328,9 +328,6 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# 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
|
||||
)
|
||||
@@ -353,9 +350,7 @@ class TelgramBotConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data={
|
||||
CONF_PLATFORM: user_input[CONF_PLATFORM],
|
||||
CONF_API_KEY: user_input[CONF_API_KEY],
|
||||
CONF_PROXY_URL: user_input[SECTION_ADVANCED_SETTINGS].get(
|
||||
CONF_PROXY_URL
|
||||
),
|
||||
CONF_PROXY_URL: user_input["advanced_settings"].get(CONF_PROXY_URL),
|
||||
},
|
||||
options={
|
||||
# this value may come from yaml import
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"name": "Tire pressure rear right"
|
||||
},
|
||||
"version": {
|
||||
"name": "Version"
|
||||
"name": "version"
|
||||
},
|
||||
"vin": {
|
||||
"name": "Vehicle"
|
||||
|
||||
@@ -247,9 +247,6 @@ class StateVacuumEntity(
|
||||
_attr_supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
|
||||
|
||||
__vacuum_legacy_state: bool = False
|
||||
__vacuum_legacy_battery_level: bool = False
|
||||
__vacuum_legacy_battery_icon: bool = False
|
||||
__vacuum_legacy_battery_feature: bool = False
|
||||
|
||||
def __init_subclass__(cls, **kwargs: Any) -> None:
|
||||
"""Post initialisation processing."""
|
||||
@@ -258,28 +255,15 @@ class StateVacuumEntity(
|
||||
# Integrations should use the 'activity' property instead of
|
||||
# setting the state directly.
|
||||
cls.__vacuum_legacy_state = True
|
||||
if any(
|
||||
method in cls.__dict__
|
||||
for method in ("_attr_battery_level", "battery_level")
|
||||
):
|
||||
# Integrations should use a separate battery sensor.
|
||||
cls.__vacuum_legacy_battery_level = True
|
||||
if any(
|
||||
method in cls.__dict__ for method in ("_attr_battery_icon", "battery_icon")
|
||||
):
|
||||
# Integrations should use a separate battery sensor.
|
||||
cls.__vacuum_legacy_battery_icon = True
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
"""Set attribute.
|
||||
|
||||
Deprecation warning if setting state, battery icon or battery level
|
||||
attributes directly unless already reported.
|
||||
Deprecation warning if setting '_attr_state' directly
|
||||
unless already reported.
|
||||
"""
|
||||
if name == "_attr_state":
|
||||
self._report_deprecated_activity_handling()
|
||||
if name in {"_attr_battery_level", "_attr_battery_icon"}:
|
||||
self._report_deprecated_battery_properties(name[6:])
|
||||
return super().__setattr__(name, value)
|
||||
|
||||
@callback
|
||||
@@ -293,10 +277,6 @@ class StateVacuumEntity(
|
||||
super().add_to_platform_start(hass, platform, parallel_updates)
|
||||
if self.__vacuum_legacy_state:
|
||||
self._report_deprecated_activity_handling()
|
||||
if self.__vacuum_legacy_battery_level:
|
||||
self._report_deprecated_battery_properties("battery_level")
|
||||
if self.__vacuum_legacy_battery_icon:
|
||||
self._report_deprecated_battery_properties("battery_icon")
|
||||
|
||||
@callback
|
||||
def _report_deprecated_activity_handling(self) -> None:
|
||||
@@ -315,42 +295,6 @@ class StateVacuumEntity(
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _report_deprecated_battery_properties(self, property: str) -> None:
|
||||
"""Report on deprecated use of battery properties.
|
||||
|
||||
Integrations should implement a sensor instead.
|
||||
"""
|
||||
report_usage(
|
||||
f"is setting the {property} which has been deprecated."
|
||||
f" Integration {self.platform.platform_name} should implement a sensor"
|
||||
" instead with a correct device class and link it to the same device",
|
||||
core_integration_behavior=ReportBehavior.LOG,
|
||||
custom_integration_behavior=ReportBehavior.LOG,
|
||||
breaks_in_ha_version="2026.7",
|
||||
integration_domain=self.platform.platform_name if self.platform else None,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
|
||||
@callback
|
||||
def _report_deprecated_battery_feature(self) -> None:
|
||||
"""Report on deprecated use of battery supported features.
|
||||
|
||||
Integrations should remove the battery supported feature when migrating
|
||||
battery level and icon to a sensor.
|
||||
"""
|
||||
report_usage(
|
||||
f"is setting the battery supported feature which has been deprecated."
|
||||
f" Integration {self.platform.platform_name} should remove this as part of migrating"
|
||||
" the battery level and icon to a sensor",
|
||||
core_behavior=ReportBehavior.LOG,
|
||||
core_integration_behavior=ReportBehavior.LOG,
|
||||
custom_integration_behavior=ReportBehavior.LOG,
|
||||
breaks_in_ha_version="2026.7",
|
||||
integration_domain=self.platform.platform_name if self.platform else None,
|
||||
exclude_integrations={DOMAIN},
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def battery_level(self) -> int | None:
|
||||
"""Return the battery level of the vacuum cleaner."""
|
||||
@@ -389,9 +333,6 @@ class StateVacuumEntity(
|
||||
supported_features = self.supported_features
|
||||
|
||||
if VacuumEntityFeature.BATTERY in supported_features:
|
||||
if self.__vacuum_legacy_battery_feature is False:
|
||||
self._report_deprecated_battery_feature()
|
||||
self.__vacuum_legacy_battery_feature = True
|
||||
data[ATTR_BATTERY_LEVEL] = self.battery_level
|
||||
data[ATTR_BATTERY_ICON] = self.battery_icon
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.storage import STORAGE_DIR
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
from .services import 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."""
|
||||
async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
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,8 +32,7 @@ from .const import (
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the velbus services."""
|
||||
|
||||
def check_entry_id(interface: str) -> str:
|
||||
|
||||
@@ -22,8 +22,6 @@ CHARGER_CURRENT_MODE_KEY = "current_mode"
|
||||
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"
|
||||
@@ -34,9 +32,7 @@ CHARGER_POWER_BOOST_KEY = "POWER_BOOST"
|
||||
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"
|
||||
|
||||
@@ -14,13 +14,11 @@ from wallbox import Wallbox
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
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,
|
||||
@@ -28,7 +26,6 @@ from .const import (
|
||||
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,
|
||||
@@ -195,10 +192,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return data # noqa: TRY300
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == 429:
|
||||
raise UpdateFailed(
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="too_many_requests"
|
||||
) from wallbox_connection_error
|
||||
raise UpdateFailed(
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN, translation_key="api_failed"
|
||||
) from wallbox_connection_error
|
||||
|
||||
@@ -207,19 +204,10 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return await self.hass.async_add_executor_job(self._get_data)
|
||||
|
||||
@_require_authentication
|
||||
def _set_charging_current(
|
||||
self, charging_current: float
|
||||
) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
def _set_charging_current(self, charging_current: float) -> None:
|
||||
"""Set maximum charging current for Wallbox."""
|
||||
try:
|
||||
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
|
||||
self._wallbox.setMaxChargingCurrent(self._station, charging_current)
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == 403:
|
||||
raise InvalidAuth from wallbox_connection_error
|
||||
@@ -233,19 +221,16 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_set_charging_current(self, charging_current: float) -> None:
|
||||
"""Set maximum charging current for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
await self.hass.async_add_executor_job(
|
||||
self._set_charging_current, charging_current
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
await self.async_request_refresh()
|
||||
|
||||
@_require_authentication
|
||||
def _set_icp_current(self, icp_current: float) -> dict[str, Any]:
|
||||
def _set_icp_current(self, icp_current: float) -> None:
|
||||
"""Set maximum icp current for Wallbox."""
|
||||
try:
|
||||
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
|
||||
self._wallbox.setIcpMaxCurrent(self._station, icp_current)
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == 403:
|
||||
raise InvalidAuth from wallbox_connection_error
|
||||
@@ -259,19 +244,14 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_set_icp_current(self, icp_current: float) -> None:
|
||||
"""Set maximum icp current for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
self._set_icp_current, icp_current
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
await self.hass.async_add_executor_job(self._set_icp_current, icp_current)
|
||||
await self.async_request_refresh()
|
||||
|
||||
@_require_authentication
|
||||
def _set_energy_cost(self, energy_cost: float) -> dict[str, Any]:
|
||||
def _set_energy_cost(self, energy_cost: float) -> None:
|
||||
"""Set energy cost for Wallbox."""
|
||||
try:
|
||||
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
|
||||
self._wallbox.setEnergyCost(self._station, energy_cost)
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == 429:
|
||||
raise HomeAssistantError(
|
||||
@@ -283,24 +263,17 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_set_energy_cost(self, energy_cost: float) -> None:
|
||||
"""Set energy cost for Wallbox."""
|
||||
data = await self.hass.async_add_executor_job(
|
||||
self._set_energy_cost, energy_cost
|
||||
)
|
||||
self.async_set_updated_data(data)
|
||||
await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost)
|
||||
await self.async_request_refresh()
|
||||
|
||||
@_require_authentication
|
||||
def _set_lock_unlock(self, lock: bool) -> dict[str, dict[str, dict[str, Any]]]:
|
||||
def _set_lock_unlock(self, lock: bool) -> None:
|
||||
"""Set wallbox to locked or unlocked."""
|
||||
try:
|
||||
if lock:
|
||||
result = self._wallbox.lockCharger(self._station)
|
||||
self._wallbox.lockCharger(self._station)
|
||||
else:
|
||||
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
|
||||
self._wallbox.unlockCharger(self._station)
|
||||
except requests.exceptions.HTTPError as wallbox_connection_error:
|
||||
if wallbox_connection_error.response.status_code == 403:
|
||||
raise InvalidAuth from wallbox_connection_error
|
||||
@@ -314,8 +287,8 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
async def async_set_lock_unlock(self, lock: bool) -> None:
|
||||
"""Set wallbox to locked or unlocked."""
|
||||
data = await self.hass.async_add_executor_job(self._set_lock_unlock, lock)
|
||||
self.async_set_updated_data(data)
|
||||
await self.hass.async_add_executor_job(self._set_lock_unlock, lock)
|
||||
await self.async_request_refresh()
|
||||
|
||||
@_require_authentication
|
||||
def _pause_charger(self, pause: bool) -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
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 (
|
||||
@@ -15,7 +16,7 @@ from .const import (
|
||||
CHARGER_SERIAL_NUMBER_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import WallboxCoordinator
|
||||
from .coordinator import InvalidAuth, WallboxCoordinator
|
||||
from .entity import WallboxEntity
|
||||
|
||||
LOCK_TYPES: dict[str, LockEntityDescription] = {
|
||||
@@ -33,6 +34,16 @@ 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
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import cast
|
||||
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 (
|
||||
@@ -25,7 +26,7 @@ from .const import (
|
||||
CHARGER_SERIAL_NUMBER_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
from .coordinator import WallboxCoordinator
|
||||
from .coordinator import InvalidAuth, WallboxCoordinator
|
||||
from .entity import WallboxEntity
|
||||
|
||||
|
||||
@@ -85,6 +86,16 @@ 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
|
||||
|
||||
@@ -5,11 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiowebdav2.exceptions import (
|
||||
AccessDeniedError,
|
||||
MethodNotSupportedError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiowebdav2.exceptions import MethodNotSupportedError, UnauthorizedError
|
||||
import voluptuous as vol
|
||||
import yarl
|
||||
|
||||
@@ -69,8 +65,6 @@ class WebDavConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
result = await client.check()
|
||||
except UnauthorizedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except AccessDeniedError:
|
||||
errors["base"] = "access_denied"
|
||||
except MethodNotSupportedError:
|
||||
errors["base"] = "invalid_method"
|
||||
except Exception:
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"access_denied": "The access to the backup path has been denied. Please check the permissions of the backup path.",
|
||||
"invalid_method": "The server does not support the required methods. Please check whether you have the correct URL. Check with your provider for the correct URL.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
@@ -36,6 +35,9 @@
|
||||
"cannot_connect": {
|
||||
"message": "Cannot connect to WebDAV server"
|
||||
},
|
||||
"cannot_access_or_create_backup_path": {
|
||||
"message": "Cannot access or create backup path. Please check the path and permissions."
|
||||
},
|
||||
"failed_to_migrate_folder": {
|
||||
"message": "Failed to migrate wrong encoded folder \"{wrong_path}\" to \"{correct_path}\"."
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
"name": "Detergent level",
|
||||
"state": {
|
||||
"unknown": "Unknown",
|
||||
"empty": "[%key:common::state::empty%]",
|
||||
"empty": "Empty",
|
||||
"25": "25%",
|
||||
"50": "50%",
|
||||
"100": "100%",
|
||||
|
||||
@@ -24,5 +24,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["xiaomi-ble==1.1.0"]
|
||||
"requirements": ["xiaomi-ble==0.39.0"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .services import async_setup_services
|
||||
from .services import register_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -81,7 +81,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
ex,
|
||||
)
|
||||
|
||||
async_setup_services(hass)
|
||||
register_services(hass)
|
||||
|
||||
hass.async_create_task(
|
||||
async_load_platform(hass, Platform.BINARY_SENSOR, DOMAIN, {}, config)
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_ID, ATTR_NAME
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -32,8 +32,7 @@ def _set_active_state(call: ServiceCall) -> None:
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_services(hass: HomeAssistant) -> None:
|
||||
"""Register ZoneMinder services."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
Generated
+1
@@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest
|
||||
|
||||
FLOWS = {
|
||||
"helper": [
|
||||
"compensation",
|
||||
"derivative",
|
||||
"filter",
|
||||
"generic_hygrostat",
|
||||
|
||||
Generated
-20
@@ -539,26 +539,6 @@ DHCP: Final[list[dict[str, str | bool]]] = [
|
||||
"domain": "playstation_network",
|
||||
"macaddress": "D44B5E*",
|
||||
},
|
||||
{
|
||||
"domain": "playstation_network",
|
||||
"macaddress": "F8D0AC*",
|
||||
},
|
||||
{
|
||||
"domain": "playstation_network",
|
||||
"macaddress": "E86E3A*",
|
||||
},
|
||||
{
|
||||
"domain": "playstation_network",
|
||||
"macaddress": "FC0FE6*",
|
||||
},
|
||||
{
|
||||
"domain": "playstation_network",
|
||||
"macaddress": "9C37CB*",
|
||||
},
|
||||
{
|
||||
"domain": "playstation_network",
|
||||
"macaddress": "84E657*",
|
||||
},
|
||||
{
|
||||
"domain": "powerwall",
|
||||
"hostname": "1118431-*",
|
||||
|
||||
@@ -1072,12 +1072,6 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"compensation": {
|
||||
"name": "Compensation",
|
||||
"integration_type": "hub",
|
||||
"config_flow": false,
|
||||
"iot_class": "calculated"
|
||||
},
|
||||
"concord232": {
|
||||
"name": "Concord232",
|
||||
"integration_type": "hub",
|
||||
@@ -7733,6 +7727,12 @@
|
||||
"config_flow": false,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"compensation": {
|
||||
"name": "Compensation",
|
||||
"integration_type": "helper",
|
||||
"config_flow": true,
|
||||
"iot_class": "calculated"
|
||||
},
|
||||
"counter": {
|
||||
"integration_type": "helper",
|
||||
"config_flow": false
|
||||
|
||||
@@ -463,6 +463,9 @@ class Entity(
|
||||
# it should be using async_write_ha_state.
|
||||
_async_update_ha_state_reported = False
|
||||
|
||||
# If we reported this entity was added without its platform set
|
||||
_no_platform_reported = False
|
||||
|
||||
# If we reported the name translation placeholders do not match the name
|
||||
_name_translation_placeholders_reported = False
|
||||
|
||||
@@ -718,6 +721,9 @@ class Entity(
|
||||
# value.
|
||||
type.__getattribute__(self.__class__, "name")
|
||||
is type.__getattribute__(Entity, "name")
|
||||
# The check for self.platform guards against integrations not using an
|
||||
# EntityComponent and can be removed in HA Core 2024.1
|
||||
and self.platform
|
||||
):
|
||||
name = self._name_internal(
|
||||
self._object_id_device_class_name,
|
||||
@@ -730,6 +736,10 @@ class Entity(
|
||||
@cached_property
|
||||
def name(self) -> str | UndefinedType | None:
|
||||
"""Return the name of the entity."""
|
||||
# The check for self.platform guards against integrations not using an
|
||||
# EntityComponent and can be removed in HA Core 2024.1
|
||||
if not self.platform:
|
||||
return self._name_internal(None, {})
|
||||
return self._name_internal(
|
||||
self._device_class_name,
|
||||
self.platform.platform_translations,
|
||||
@@ -974,7 +984,7 @@ class Entity(
|
||||
|
||||
# The check for self.platform guards against integrations not using an
|
||||
# EntityComponent and can be removed in HA Core 2024.1
|
||||
if self.platform is None:
|
||||
if self.platform is None and not self._no_platform_reported: # type: ignore[unreachable]
|
||||
report_issue = self._suggest_report_issue() # type: ignore[unreachable]
|
||||
_LOGGER.warning(
|
||||
(
|
||||
@@ -986,7 +996,7 @@ class Entity(
|
||||
type(self),
|
||||
report_issue,
|
||||
)
|
||||
raise HomeAssistantError("Entity does not have a platform")
|
||||
self._no_platform_reported = True
|
||||
|
||||
if self.entity_id is None:
|
||||
raise NoEntitySpecifiedError(
|
||||
@@ -1005,6 +1015,8 @@ class Entity(
|
||||
@callback
|
||||
def async_write_ha_state(self) -> None:
|
||||
"""Write the state to the state machine."""
|
||||
if not self.hass or not self._verified_state_writable:
|
||||
self._async_verify_state_writable()
|
||||
if self.hass.loop_thread_id != threading.get_ident():
|
||||
report_non_thread_safe_operation("async_write_ha_state")
|
||||
self._async_write_ha_state()
|
||||
@@ -1114,9 +1126,6 @@ class Entity(
|
||||
# Polling returned after the entity has already been removed
|
||||
return
|
||||
|
||||
if not self.hass or not self._verified_state_writable:
|
||||
self._async_verify_state_writable()
|
||||
|
||||
if (entry := self.registry_entry) and entry.disabled_by:
|
||||
if not self._disabled_reported:
|
||||
self._disabled_reported = True
|
||||
@@ -1477,7 +1486,10 @@ class Entity(
|
||||
|
||||
Not to be extended by integrations.
|
||||
"""
|
||||
del entity_sources(self.hass)[self.entity_id]
|
||||
# The check for self.platform guards against integrations not using an
|
||||
# EntityComponent and can be removed in HA Core 2024.1
|
||||
if self.platform:
|
||||
del entity_sources(self.hass)[self.entity_id]
|
||||
|
||||
@callback
|
||||
def _async_registry_updated(
|
||||
@@ -1608,7 +1620,7 @@ class Entity(
|
||||
def _suggest_report_issue(self) -> str:
|
||||
"""Suggest to report an issue."""
|
||||
# The check for self.platform guards against integrations not using an
|
||||
# EntityComponent which has not been allowed since HA Core 2024.1
|
||||
# EntityComponent and can be removed in HA Core 2024.1
|
||||
platform_name = self.platform.platform_name if self.platform else None
|
||||
return async_suggest_report_issue(
|
||||
self.hass, integration_domain=platform_name, module=type(self).__module__
|
||||
|
||||
@@ -1043,18 +1043,9 @@ class MediaSelector(Selector[MediaSelectorConfig]):
|
||||
"""Instantiate a selector."""
|
||||
super().__init__(config)
|
||||
|
||||
def __call__(self, data: Any) -> dict[str, str]:
|
||||
def __call__(self, data: Any) -> dict[str, float]:
|
||||
"""Validate the passed selection."""
|
||||
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)
|
||||
media: dict[str, float] = self.DATA_SCHEMA(data)
|
||||
return media
|
||||
|
||||
|
||||
|
||||
@@ -12,9 +12,3 @@ 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"
|
||||
"""
|
||||
|
||||
@@ -128,11 +128,9 @@
|
||||
"disabled": "Disabled",
|
||||
"discharging": "Discharging",
|
||||
"disconnected": "Disconnected",
|
||||
"empty": "Empty",
|
||||
"enabled": "Enabled",
|
||||
"error": "Error",
|
||||
"fault": "Fault",
|
||||
"full": "Full",
|
||||
"high": "High",
|
||||
"home": "Home",
|
||||
"idle": "Idle",
|
||||
|
||||
Generated
+1
-1
@@ -3126,7 +3126,7 @@ wyoming==1.7.1
|
||||
xbox-webapi==2.1.0
|
||||
|
||||
# homeassistant.components.xiaomi_ble
|
||||
xiaomi-ble==1.1.0
|
||||
xiaomi-ble==0.39.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.8.0
|
||||
|
||||
Generated
+1
-1
@@ -2579,7 +2579,7 @@ wyoming==1.7.1
|
||||
xbox-webapi==2.1.0
|
||||
|
||||
# homeassistant.components.xiaomi_ble
|
||||
xiaomi-ble==1.1.0
|
||||
xiaomi-ble==0.39.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknx==3.8.0
|
||||
|
||||
@@ -23,17 +23,17 @@ DHCP_SERVICE_INFO = [
|
||||
DhcpServiceInfo(
|
||||
hostname="airthings-view",
|
||||
ip="192.168.1.100",
|
||||
macaddress="000000000000",
|
||||
macaddress="00:00:00:00:00:00",
|
||||
),
|
||||
DhcpServiceInfo(
|
||||
hostname="airthings-hub",
|
||||
ip="192.168.1.101",
|
||||
macaddress="d01411900000",
|
||||
macaddress="D0:14:11:90:00:00",
|
||||
),
|
||||
DhcpServiceInfo(
|
||||
hostname="airthings-hub",
|
||||
ip="192.168.1.102",
|
||||
macaddress="70b3d52a0000",
|
||||
macaddress="70:B3:D5:2A:00:00",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ async def test_conversation_agent(
|
||||
assert agent.supported_languages == "*"
|
||||
|
||||
|
||||
@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")
|
||||
@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools")
|
||||
@pytest.mark.parametrize(
|
||||
("tool_call_json_parts", "expected_call_tool_args"),
|
||||
[
|
||||
@@ -430,7 +430,7 @@ async def test_function_call(
|
||||
)
|
||||
|
||||
|
||||
@patch("homeassistant.components.anthropic.entity.llm.AssistAPI._async_get_tools")
|
||||
@patch("homeassistant.components.anthropic.conversation.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.entity.llm.AssistAPI._async_get_tools")
|
||||
@patch("homeassistant.components.anthropic.conversation.llm.AssistAPI._async_get_tools")
|
||||
async def test_extended_thinking_tool_call(
|
||||
mock_get_tools,
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -12,7 +12,6 @@ from httpx import URL, Request, Response
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.anthropic.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigSubentryData
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -114,7 +113,6 @@ async def test_migration_from_v1_to_v2(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.version == 2
|
||||
assert mock_config_entry.minor_version == 2
|
||||
assert mock_config_entry.data == {"api_key": "1234"}
|
||||
assert mock_config_entry.options == {}
|
||||
|
||||
@@ -226,7 +224,6 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
|
||||
|
||||
for idx, entry in enumerate(entries):
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert len(entry.subentries) == 1
|
||||
subentry = list(entry.subentries.values())[0]
|
||||
@@ -320,7 +317,6 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert len(entry.subentries) == 2 # Two subentries from the two original entries
|
||||
|
||||
@@ -343,160 +339,3 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
assert dev.config_entries_subentries == {
|
||||
mock_config_entry.entry_id: {subentry.subentry_id}
|
||||
}
|
||||
|
||||
|
||||
async def test_migration_from_v2_1_to_v2_2(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test migration from version 2.1 to version 2.2.
|
||||
|
||||
This tests we clean up the broken migration in Home Assistant Core
|
||||
2025.7.0b0-2025.7.0b1:
|
||||
- Fix device registry (Fixed in Home Assistant Core 2025.7.0b2)
|
||||
"""
|
||||
# Create a v2.1 config entry with 2 subentries, devices and entities
|
||||
options = {
|
||||
"recommended": True,
|
||||
"llm_hass_api": ["assist"],
|
||||
"prompt": "You are a helpful assistant",
|
||||
"chat_model": "claude-3-haiku-20240307",
|
||||
}
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={"api_key": "1234"},
|
||||
entry_id="mock_entry_id",
|
||||
version=2,
|
||||
minor_version=1,
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data=options,
|
||||
subentry_id="mock_id_1",
|
||||
subentry_type="conversation",
|
||||
title="Claude",
|
||||
unique_id=None,
|
||||
),
|
||||
ConfigSubentryData(
|
||||
data=options,
|
||||
subentry_id="mock_id_2",
|
||||
subentry_type="conversation",
|
||||
title="Claude 2",
|
||||
unique_id=None,
|
||||
),
|
||||
],
|
||||
title="Claude",
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device_1 = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
config_subentry_id="mock_id_1",
|
||||
identifiers={(DOMAIN, "mock_id_1")},
|
||||
name="Claude",
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
device_1 = device_registry.async_update_device(
|
||||
device_1.id, add_config_entry_id="mock_entry_id", add_config_subentry_id=None
|
||||
)
|
||||
assert device_1.config_entries_subentries == {"mock_entry_id": {None, "mock_id_1"}}
|
||||
entity_registry.async_get_or_create(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
"mock_id_1",
|
||||
config_entry=mock_config_entry,
|
||||
config_subentry_id="mock_id_1",
|
||||
device_id=device_1.id,
|
||||
suggested_object_id="claude",
|
||||
)
|
||||
|
||||
device_2 = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
config_subentry_id="mock_id_2",
|
||||
identifiers={(DOMAIN, "mock_id_2")},
|
||||
name="Claude 2",
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
"mock_id_2",
|
||||
config_entry=mock_config_entry,
|
||||
config_subentry_id="mock_id_2",
|
||||
device_id=device_2.id,
|
||||
suggested_object_id="claude_2",
|
||||
)
|
||||
|
||||
# Run migration
|
||||
with patch(
|
||||
"homeassistant.components.anthropic.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert entry.title == "Claude"
|
||||
assert len(entry.subentries) == 2
|
||||
conversation_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == "conversation"
|
||||
]
|
||||
assert len(conversation_subentries) == 2
|
||||
for subentry in conversation_subentries:
|
||||
assert subentry.subentry_type == "conversation"
|
||||
assert subentry.data == options
|
||||
assert "Claude" in subentry.title
|
||||
|
||||
subentry = conversation_subentries[0]
|
||||
|
||||
entity = entity_registry.async_get("conversation.claude")
|
||||
assert entity.unique_id == subentry.subentry_id
|
||||
assert entity.config_subentry_id == subentry.subentry_id
|
||||
assert entity.config_entry_id == entry.entry_id
|
||||
|
||||
assert not device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)}
|
||||
)
|
||||
assert (
|
||||
device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)}
|
||||
)
|
||||
)
|
||||
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
|
||||
assert device.id == device_1.id
|
||||
assert device.config_entries == {mock_config_entry.entry_id}
|
||||
assert device.config_entries_subentries == {
|
||||
mock_config_entry.entry_id: {subentry.subentry_id}
|
||||
}
|
||||
|
||||
subentry = conversation_subentries[1]
|
||||
|
||||
entity = entity_registry.async_get("conversation.claude_2")
|
||||
assert entity.unique_id == subentry.subentry_id
|
||||
assert entity.config_subentry_id == subentry.subentry_id
|
||||
assert entity.config_entry_id == entry.entry_id
|
||||
assert not device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)}
|
||||
)
|
||||
assert (
|
||||
device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)}
|
||||
)
|
||||
)
|
||||
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
|
||||
assert device.id == device_2.id
|
||||
assert device.config_entries == {mock_config_entry.entry_id}
|
||||
assert device.config_entries_subentries == {
|
||||
mock_config_entry.entry_id: {subentry.subentry_id}
|
||||
}
|
||||
|
||||
@@ -235,43 +235,6 @@ async def test_new_pipeline_cancels_pipeline(
|
||||
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(
|
||||
@@ -647,51 +610,6 @@ 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")
|
||||
@@ -813,10 +731,6 @@ async def test_start_conversation_default_preannounce(
|
||||
),
|
||||
(
|
||||
{
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Fixtures for the Compensation integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.compensation.const import (
|
||||
CONF_DATAPOINTS,
|
||||
CONF_DEGREE,
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_PRECISION,
|
||||
CONF_UPPER_LIMIT,
|
||||
DEFAULT_DEGREE,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_USER
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Automatically patch compensation setup_entry."""
|
||||
with patch(
|
||||
"homeassistant.components.compensation.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture(name="get_config")
|
||||
async def get_config_to_integration_load() -> dict[str, Any]:
|
||||
"""Return configuration.
|
||||
|
||||
To override the config, tests can be marked with:
|
||||
@pytest.mark.parametrize("get_config", [{...}])
|
||||
"""
|
||||
return {
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.uncompensated",
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: DEFAULT_DEGREE,
|
||||
CONF_UNIT_OF_MEASUREMENT: "mm",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(name="loaded_entry")
|
||||
async def load_integration(
|
||||
hass: HomeAssistant, get_config: dict[str, Any]
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the Compensation integration in Home Assistant."""
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Compensation sensor",
|
||||
source=SOURCE_USER,
|
||||
options=get_config,
|
||||
entry_id="1",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
entity_id = get_config[CONF_ENTITY_ID]
|
||||
hass.states.async_set(entity_id, 4, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
return config_entry
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Test the Compensation config flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.compensation.const import (
|
||||
CONF_DATAPOINTS,
|
||||
CONF_DEGREE,
|
||||
CONF_LOWER_LIMIT,
|
||||
CONF_PRECISION,
|
||||
CONF_UPPER_LIMIT,
|
||||
DEFAULT_NAME,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_UNIT_OF_MEASUREMENT
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test we get the form."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.test_monitored",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "mm",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["version"] == 1
|
||||
assert result["options"] == {
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.test_monitored",
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "mm",
|
||||
}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_options_flow(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
|
||||
"""Test options flow."""
|
||||
|
||||
result = await hass.config_entries.options.async_init(loaded_entry.entry_id)
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.uncompensated",
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
}
|
||||
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Check the entity was updated, no new entity was created
|
||||
assert len(hass.states.async_all()) == 2
|
||||
|
||||
state = hass.states.get("sensor.compensation_sensor")
|
||||
assert state is not None
|
||||
|
||||
|
||||
async def test_validation_options(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test validation."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.test_monitored",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 2,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "not_enough_datapoints"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "incorrect_datapoints"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2,0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "incorrect_datapoints"}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_DATAPOINTS: ["1.0, 2.0", "2.0, 3.0", "3.0, 4.0"],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 2,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result["version"] == 1
|
||||
assert result["options"] == {
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.test_monitored",
|
||||
CONF_DATAPOINTS: ["1.0, 2.0", "2.0, 3.0", "3.0, 4.0"],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 2,
|
||||
CONF_UNIT_OF_MEASUREMENT: "km",
|
||||
}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_entry_already_exist(
|
||||
hass: HomeAssistant, loaded_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Test abort when entry already exist."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["step_id"] == "user"
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_NAME: DEFAULT_NAME,
|
||||
CONF_ENTITY_ID: "sensor.uncompensated",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_DATAPOINTS: [
|
||||
"1.0, 2.0",
|
||||
"2.0, 3.0",
|
||||
],
|
||||
CONF_UPPER_LIMIT: False,
|
||||
CONF_LOWER_LIMIT: False,
|
||||
CONF_PRECISION: 2,
|
||||
CONF_DEGREE: 1,
|
||||
CONF_UNIT_OF_MEASUREMENT: "mm",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Test Statistics component setup process."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.compensation.const import DOMAIN
|
||||
from homeassistant.config_entries import SOURCE_USER, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) -> None:
|
||||
"""Test unload an entry."""
|
||||
|
||||
assert loaded_entry.state is ConfigEntryState.LOADED
|
||||
assert await hass.config_entries.async_unload(loaded_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert loaded_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_could_not_setup(hass: HomeAssistant, get_config: dict[str, Any]) -> None:
|
||||
"""Test exception."""
|
||||
|
||||
config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="Compensation sensor",
|
||||
source=SOURCE_USER,
|
||||
options=get_config,
|
||||
entry_id="1",
|
||||
)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.compensation.np.polyfit",
|
||||
side_effect=FloatingPointError,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
assert config_entry.error_reason_translation_key == "setup_error"
|
||||
@@ -1,5 +1,7 @@
|
||||
"""The tests for the integration sensor platform."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.compensation.const import CONF_PRECISION, DOMAIN
|
||||
@@ -7,6 +9,8 @@ from homeassistant.components.compensation.sensor import ATTR_COEFFICIENTS
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
EVENT_STATE_CHANGED,
|
||||
STATE_UNKNOWN,
|
||||
@@ -14,6 +18,24 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_not_loading_from_platform_yaml(hass: HomeAssistant) -> None:
|
||||
"""Test compensation sensor not loaded from platform YAML."""
|
||||
config = {
|
||||
"sensor": [
|
||||
{
|
||||
CONF_PLATFORM: DOMAIN,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert await async_setup_component(hass, SENSOR_DOMAIN, config)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(hass.states.async_all()) == 0
|
||||
|
||||
|
||||
async def test_linear_state(hass: HomeAssistant) -> None:
|
||||
"""Test compensation sensor state."""
|
||||
@@ -60,6 +82,34 @@ async def test_linear_state(hass: HomeAssistant) -> None:
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def test_linear_state_from_config_entry(
|
||||
hass: HomeAssistant, loaded_entry: MockConfigEntry, get_config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Test compensation sensor state loaded from config entry."""
|
||||
expected_entity_id = "sensor.compensation_sensor"
|
||||
entity_id = get_config[CONF_ENTITY_ID]
|
||||
|
||||
hass.states.async_set(entity_id, 5, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(expected_entity_id)
|
||||
assert state is not None
|
||||
assert round(float(state.state), get_config[CONF_PRECISION]) == 6.0
|
||||
|
||||
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mm"
|
||||
|
||||
coefs = [round(v, 1) for v in state.attributes.get(ATTR_COEFFICIENTS)]
|
||||
assert coefs == [1.0, 1.0]
|
||||
|
||||
hass.states.async_set(entity_id, "foo", {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(expected_entity_id)
|
||||
assert state is not None
|
||||
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
async def test_linear_state_from_attribute(hass: HomeAssistant) -> None:
|
||||
"""Test compensation sensor state that pulls from attribute."""
|
||||
config = {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
"""The test for the Coolmaster integration."""
|
||||
|
||||
from homeassistant.components.coolmaster.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
async def test_load_entry(
|
||||
@@ -27,45 +22,3 @@ async def test_unload_entry(
|
||||
await hass.config_entries.async_unload(load_int.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert load_int.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_registry_cleanup(
|
||||
hass: HomeAssistant,
|
||||
load_int: ConfigEntry,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
) -> None:
|
||||
"""Test being able to remove a disconnected device."""
|
||||
entry_id = load_int.entry_id
|
||||
device_registry = dr.async_get(hass)
|
||||
live_id = "L1.100"
|
||||
dead_id = "L2.200"
|
||||
|
||||
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=entry_id,
|
||||
identifiers={(DOMAIN, dead_id)},
|
||||
manufacturer="CoolAutomation",
|
||||
model="CoolMasterNet",
|
||||
name=dead_id,
|
||||
sw_version="1.0",
|
||||
)
|
||||
|
||||
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3
|
||||
|
||||
assert await async_setup_component(hass, "config", {})
|
||||
client = await hass_ws_client(hass)
|
||||
# Try to remove "L1.100" - fails since it is live
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)})
|
||||
assert device is not None
|
||||
response = await client.remove_device(device.id, entry_id)
|
||||
assert not response["success"]
|
||||
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 3
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None
|
||||
|
||||
# Try to remove "L2.200" - succeeds since it is dead
|
||||
device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)})
|
||||
assert device is not None
|
||||
response = await client.remove_device(device.id, entry_id)
|
||||
assert response["success"]
|
||||
assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2
|
||||
assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None
|
||||
|
||||
@@ -356,7 +356,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Hub DROP-1_C0FFEE Total water used today',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -372,7 +372,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Hub DROP-1_C0FFEE Total water used today',
|
||||
'state_class': <SensorStateClass.TOTAL_INCREASING: 'total_increasing'>,
|
||||
'state_class': <SensorStateClass.TOTAL: 'total'>,
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -375,7 +375,7 @@ async def test_deep_sleep_device(
|
||||
assert state.state == STATE_ON
|
||||
state = hass.states.get("sensor.test_my_sensor")
|
||||
assert state is not None
|
||||
assert state.state == "123.0"
|
||||
assert state.state == "123"
|
||||
|
||||
await mock_device.mock_disconnect(False)
|
||||
await hass.async_block_till_done()
|
||||
@@ -394,7 +394,7 @@ async def test_deep_sleep_device(
|
||||
assert state.state == STATE_ON
|
||||
state = hass.states.get("sensor.test_my_sensor")
|
||||
assert state is not None
|
||||
assert state.state == "123.0"
|
||||
assert state.state == "123"
|
||||
|
||||
await mock_device.mock_disconnect(True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -13,28 +13,18 @@ from aioesphomeapi import (
|
||||
TextSensorInfo,
|
||||
TextSensorState,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
ATTR_STATE_CLASS,
|
||||
SensorDeviceClass,
|
||||
SensorStateClass,
|
||||
async_rounded_state,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_DEVICE_CLASS,
|
||||
ATTR_ICON,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
PERCENTAGE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
UnitOfElectricCurrent,
|
||||
UnitOfElectricPotential,
|
||||
UnitOfEnergy,
|
||||
UnitOfPower,
|
||||
UnitOfPressure,
|
||||
UnitOfTemperature,
|
||||
UnitOfVolume,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
@@ -451,63 +441,3 @@ async def test_generic_numeric_sensor_empty_string_uom(
|
||||
assert state is not None
|
||||
assert state.state == "123"
|
||||
assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_class", "unit_of_measurement", "state_value", "expected_precision"),
|
||||
[
|
||||
(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 23.456, 1),
|
||||
(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, 0.1, 1),
|
||||
(SensorDeviceClass.TEMPERATURE, UnitOfTemperature.CELSIUS, -25.789, 1),
|
||||
(SensorDeviceClass.POWER, UnitOfPower.WATT, 1234.56, 0),
|
||||
(SensorDeviceClass.POWER, UnitOfPower.WATT, 1.23456, 3),
|
||||
(SensorDeviceClass.POWER, UnitOfPower.WATT, 0.123, 3),
|
||||
(SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 1234.5, 0),
|
||||
(SensorDeviceClass.ENERGY, UnitOfEnergy.WATT_HOUR, 12.3456, 2),
|
||||
(SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 230.45, 1),
|
||||
(SensorDeviceClass.VOLTAGE, UnitOfElectricPotential.VOLT, 3.3, 1),
|
||||
(SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 15.678, 2),
|
||||
(SensorDeviceClass.CURRENT, UnitOfElectricCurrent.AMPERE, 0.015, 3),
|
||||
(SensorDeviceClass.ATMOSPHERIC_PRESSURE, UnitOfPressure.HPA, 1013.25, 1),
|
||||
(SensorDeviceClass.PRESSURE, UnitOfPressure.BAR, 1.01325, 3),
|
||||
(SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 45.67, 1),
|
||||
(SensorDeviceClass.VOLUME, UnitOfVolume.LITERS, 4567.0, 0),
|
||||
(SensorDeviceClass.HUMIDITY, PERCENTAGE, 87.654, 1),
|
||||
(SensorDeviceClass.HUMIDITY, PERCENTAGE, 45.2, 1),
|
||||
(SensorDeviceClass.BATTERY, PERCENTAGE, 95.2, 1),
|
||||
(SensorDeviceClass.BATTERY, PERCENTAGE, 100.0, 1),
|
||||
],
|
||||
)
|
||||
async def test_suggested_display_precision_by_device_class(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
device_class: SensorDeviceClass,
|
||||
unit_of_measurement: str,
|
||||
state_value: float,
|
||||
expected_precision: int,
|
||||
) -> None:
|
||||
"""Test suggested display precision for different device classes."""
|
||||
entity_info = [
|
||||
SensorInfo(
|
||||
object_id="mysensor",
|
||||
key=1,
|
||||
name="my sensor",
|
||||
unique_id="my_sensor",
|
||||
accuracy_decimals=expected_precision,
|
||||
device_class=device_class.value,
|
||||
unit_of_measurement=unit_of_measurement,
|
||||
)
|
||||
]
|
||||
states = [SensorState(key=1, state=state_value)]
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=states,
|
||||
)
|
||||
|
||||
state = hass.states.get("sensor.test_my_sensor")
|
||||
assert state is not None
|
||||
assert float(
|
||||
async_rounded_state(hass, "sensor.test_my_sensor", state)
|
||||
) == pytest.approx(round(state_value, expected_precision))
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.google_generative_ai_conversation.const import (
|
||||
DOMAIN,
|
||||
RECOMMENDED_TTS_OPTIONS,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState, ConfigSubentryData
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -473,7 +473,6 @@ async def test_migration_from_v1_to_v2(
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 3
|
||||
@@ -619,7 +618,6 @@ async def test_migration_from_v1_to_v2_with_multiple_keys(
|
||||
|
||||
for entry in entries:
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 2
|
||||
@@ -718,7 +716,6 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 3
|
||||
@@ -787,218 +784,6 @@ async def test_migration_from_v1_to_v2_with_same_keys(
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_changes", "extra_subentries", "expected_device_subentries"),
|
||||
[
|
||||
# Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b0:
|
||||
# Wrong device registry, no TTS subentry
|
||||
(
|
||||
{"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None},
|
||||
[],
|
||||
{"mock_entry_id": {None, "mock_id_1"}},
|
||||
),
|
||||
# Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b1:
|
||||
# Wrong device registry, TTS subentry created
|
||||
(
|
||||
{"add_config_entry_id": "mock_entry_id", "add_config_subentry_id": None},
|
||||
[
|
||||
ConfigSubentryData(
|
||||
data=RECOMMENDED_TTS_OPTIONS,
|
||||
subentry_id="mock_id_3",
|
||||
subentry_type="tts",
|
||||
title=DEFAULT_TTS_NAME,
|
||||
unique_id=None,
|
||||
)
|
||||
],
|
||||
{"mock_entry_id": {None, "mock_id_1"}},
|
||||
),
|
||||
# Scenario where we have a v2.1 config entry migrated by HA Core 2025.7.0b2
|
||||
# or later: Correct device registry, TTS subentry created
|
||||
(
|
||||
{},
|
||||
[
|
||||
ConfigSubentryData(
|
||||
data=RECOMMENDED_TTS_OPTIONS,
|
||||
subentry_id="mock_id_3",
|
||||
subentry_type="tts",
|
||||
title=DEFAULT_TTS_NAME,
|
||||
unique_id=None,
|
||||
)
|
||||
],
|
||||
{"mock_entry_id": {"mock_id_1"}},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_migration_from_v2_1_to_v2_2(
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_changes: dict[str, str],
|
||||
extra_subentries: list[ConfigSubentryData],
|
||||
expected_device_subentries: dict[str, set[str | None]],
|
||||
) -> None:
|
||||
"""Test migration from version 2.1 to version 2.2.
|
||||
|
||||
This tests we clean up the broken migration in Home Assistant Core
|
||||
2025.7.0b0-2025.7.0b1:
|
||||
- Fix device registry (Fixed in Home Assistant Core 2025.7.0b2)
|
||||
- Add TTS subentry (Added in Home Assistant Core 2025.7.0b1)
|
||||
"""
|
||||
# Create a v2.1 config entry with 2 subentries, devices and entities
|
||||
options = {
|
||||
"recommended": True,
|
||||
"llm_hass_api": ["assist"],
|
||||
"prompt": "You are a helpful assistant",
|
||||
"chat_model": "models/gemini-2.0-flash",
|
||||
}
|
||||
mock_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
data={CONF_API_KEY: "1234"},
|
||||
entry_id="mock_entry_id",
|
||||
version=2,
|
||||
minor_version=1,
|
||||
subentries_data=[
|
||||
ConfigSubentryData(
|
||||
data=options,
|
||||
subentry_id="mock_id_1",
|
||||
subentry_type="conversation",
|
||||
title="Google Generative AI",
|
||||
unique_id=None,
|
||||
),
|
||||
ConfigSubentryData(
|
||||
data=options,
|
||||
subentry_id="mock_id_2",
|
||||
subentry_type="conversation",
|
||||
title="Google Generative AI 2",
|
||||
unique_id=None,
|
||||
),
|
||||
*extra_subentries,
|
||||
],
|
||||
title="Google Generative AI",
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
device_1 = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
config_subentry_id="mock_id_1",
|
||||
identifiers={(DOMAIN, "mock_id_1")},
|
||||
name="Google Generative AI",
|
||||
manufacturer="Google",
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
device_1 = device_registry.async_update_device(device_1.id, **device_changes)
|
||||
assert device_1.config_entries_subentries == expected_device_subentries
|
||||
entity_registry.async_get_or_create(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
"mock_id_1",
|
||||
config_entry=mock_config_entry,
|
||||
config_subentry_id="mock_id_1",
|
||||
device_id=device_1.id,
|
||||
suggested_object_id="google_generative_ai_conversation",
|
||||
)
|
||||
|
||||
device_2 = device_registry.async_get_or_create(
|
||||
config_entry_id=mock_config_entry.entry_id,
|
||||
config_subentry_id="mock_id_2",
|
||||
identifiers={(DOMAIN, "mock_id_2")},
|
||||
name="Google Generative AI 2",
|
||||
manufacturer="Google",
|
||||
model="Generative AI",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
entity_registry.async_get_or_create(
|
||||
"conversation",
|
||||
DOMAIN,
|
||||
"mock_id_2",
|
||||
config_entry=mock_config_entry,
|
||||
config_subentry_id="mock_id_2",
|
||||
device_id=device_2.id,
|
||||
suggested_object_id="google_generative_ai_conversation_2",
|
||||
)
|
||||
|
||||
# Run migration
|
||||
with patch(
|
||||
"homeassistant.components.google_generative_ai_conversation.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entries = hass.config_entries.async_entries(DOMAIN)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.version == 2
|
||||
assert entry.minor_version == 2
|
||||
assert not entry.options
|
||||
assert entry.title == DEFAULT_TITLE
|
||||
assert len(entry.subentries) == 3
|
||||
conversation_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == "conversation"
|
||||
]
|
||||
assert len(conversation_subentries) == 2
|
||||
for subentry in conversation_subentries:
|
||||
assert subentry.subentry_type == "conversation"
|
||||
assert subentry.data == options
|
||||
assert "Google Generative AI" in subentry.title
|
||||
tts_subentries = [
|
||||
subentry
|
||||
for subentry in entry.subentries.values()
|
||||
if subentry.subentry_type == "tts"
|
||||
]
|
||||
assert len(tts_subentries) == 1
|
||||
assert tts_subentries[0].data == RECOMMENDED_TTS_OPTIONS
|
||||
assert tts_subentries[0].title == DEFAULT_TTS_NAME
|
||||
|
||||
subentry = conversation_subentries[0]
|
||||
|
||||
entity = entity_registry.async_get("conversation.google_generative_ai_conversation")
|
||||
assert entity.unique_id == subentry.subentry_id
|
||||
assert entity.config_subentry_id == subentry.subentry_id
|
||||
assert entity.config_entry_id == entry.entry_id
|
||||
|
||||
assert not device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)}
|
||||
)
|
||||
assert (
|
||||
device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)}
|
||||
)
|
||||
)
|
||||
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
|
||||
assert device.id == device_1.id
|
||||
assert device.config_entries == {mock_config_entry.entry_id}
|
||||
assert device.config_entries_subentries == {
|
||||
mock_config_entry.entry_id: {subentry.subentry_id}
|
||||
}
|
||||
|
||||
subentry = conversation_subentries[1]
|
||||
|
||||
entity = entity_registry.async_get(
|
||||
"conversation.google_generative_ai_conversation_2"
|
||||
)
|
||||
assert entity.unique_id == subentry.subentry_id
|
||||
assert entity.config_subentry_id == subentry.subentry_id
|
||||
assert entity.config_entry_id == entry.entry_id
|
||||
assert not device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, mock_config_entry.entry_id)}
|
||||
)
|
||||
assert (
|
||||
device := device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)}
|
||||
)
|
||||
)
|
||||
assert device.identifiers == {(DOMAIN, subentry.subentry_id)}
|
||||
assert device.id == device_2.id
|
||||
assert device.config_entries == {mock_config_entry.entry_id}
|
||||
assert device.config_entries_subentries == {
|
||||
mock_config_entry.entry_id: {subentry.subentry_id}
|
||||
}
|
||||
|
||||
|
||||
async def test_devices(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user