Compare commits

..

5 Commits

Author SHA1 Message Date
G Johansson ed68a21afd Don't load from platform yaml 2025-06-30 16:42:26 +00:00
G Johansson 612cc91423 Add tests 2025-06-30 16:42:25 +00:00
G Johansson 170989ef30 Fixes 2025-06-30 16:42:25 +00:00
G Johansson 4aebf41c59 Fixes 2025-06-30 16:42:25 +00:00
G Johansson abbaaf4ff5 Add config flow to compensation helper 2025-06-30 16:42:15 +00:00
131 changed files with 2097 additions and 3302 deletions
+2 -2
View File
@@ -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 Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt 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 Claudes internal reasoning has been automatically "
"encrypted for safety reasons. This doesnt 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": {
+2 -3
View File
@@ -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,
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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])
+14 -14
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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(
+2 -2
View File
@@ -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:
+5 -4
View File
@@ -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."""
+254 -9
View File
@@ -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)
-258
View File
@@ -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"
}
}
}
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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"
+2 -61
View File
@@ -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
+2 -2
View File
@@ -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
+2 -3
View File
@@ -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"
+20 -47
View File
@@ -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:
+12 -1
View File
@@ -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 -1
View File
@@ -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:
+3 -1
View File
@@ -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(
+1
View File
@@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest
FLOWS = {
"helper": [
"compensation",
"derivative",
"filter",
"generic_hygrostat",
-20
View File
@@ -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-*",
+6 -6
View File
@@ -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
+19 -7
View File
@@ -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__
+2 -11
View File
@@ -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"
"""
-2
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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,
-161
View File
@@ -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",
+81
View File
@@ -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 = {
-47
View File
@@ -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>,
+2 -2
View File
@@ -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()
-70
View File
@@ -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