mirror of
https://github.com/home-assistant/core.git
synced 2026-01-07 07:38:13 +00:00
Compare commits
2 Commits
enforce_en
...
joostlek-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7397fc32 | ||
|
|
581fd34264 |
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -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.0
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.29.2
|
||||
uses: github/codeql-action/analyze@v3.29.0
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -75,6 +75,7 @@ from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
backup,
|
||||
category_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
@@ -879,6 +880,10 @@ async def _async_set_up_integrations(
|
||||
if "recorder" in all_domains:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
||||
# Initialize backup
|
||||
if "backup" in all_domains:
|
||||
backup.async_initialize_backup(hass)
|
||||
|
||||
stages: list[tuple[str, set[str], int | None]] = [
|
||||
*(
|
||||
(name, domain_group, timeout)
|
||||
|
||||
@@ -61,8 +61,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -71,13 +69,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -147,34 +138,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_CONVERSATION_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -75,7 +75,6 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Anthropic."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,17 +1,69 @@
|
||||
"""Conversation support for Anthropic."""
|
||||
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
THINKING_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -30,10 +82,253 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ToolParam:
|
||||
"""Format tool specification."""
|
||||
return ToolParam(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
)
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
tool_result_block,
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
|
||||
elif isinstance(content, conversation.UserContent):
|
||||
# Combine consequent user messages
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=content.content,
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
TextBlockParam(type="text", text=content.content),
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append( # type: ignore[attr-defined]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
elif isinstance(content, conversation.AssistantContent):
|
||||
# Combine consequent assistant messages
|
||||
if not messages or messages[-1]["role"] != "assistant":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="assistant",
|
||||
content=[],
|
||||
)
|
||||
)
|
||||
|
||||
if content.content:
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
if content.tool_calls:
|
||||
messages[-1]["content"].extend( # type: ignore[union-attr]
|
||||
[
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
messages: list[MessageParam],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- ...
|
||||
- RawContentBlockDeltaEvent with a SignatureDelta
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
|
||||
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_message: MessageParam | None = None
|
||||
current_block: (
|
||||
TextBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ThinkingBlockParam
|
||||
| RedactedThinkingBlockParam
|
||||
| None
|
||||
) = None
|
||||
current_tool_args: str
|
||||
input_usage: Usage | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
current_message = MessageParam(role=response.message.role, content=[])
|
||||
input_usage = response.message.usage
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
current_block = TextBlockParam(
|
||||
type="text", text=response.content_block.text
|
||||
)
|
||||
yield {"role": "assistant"}
|
||||
if response.content_block.text:
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
current_block = ThinkingBlockParam(
|
||||
type="thinking",
|
||||
thinking=response.content_block.thinking,
|
||||
signature=response.content_block.signature,
|
||||
)
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
current_block = RedactedThinkingBlockParam(
|
||||
type="redacted_thinking", data=response.content_block.data
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected delta without a block")
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
text_block = cast(TextBlockParam, current_block)
|
||||
text_block["text"] += response.delta.text
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["thinking"] += response.delta.thinking
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["signature"] += response.delta.signature
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
current_message["content"].append(current_block) # type: ignore[union-attr]
|
||||
current_block = None
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
current_message = None
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AnthropicConversationEntity(
|
||||
conversation.ConversationEntity,
|
||||
conversation.AbstractConversationAgent,
|
||||
AnthropicBaseLLMEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""Anthropic conversation agent."""
|
||||
|
||||
@@ -41,7 +336,17 @@ class AnthropicConversationEntity(
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -52,6 +357,13 @@ class AnthropicConversationEntity(
|
||||
"""Return a list of supported languages."""
|
||||
return MATCH_ALL
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
@@ -82,3 +394,77 @@ class AnthropicConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools or NOT_GIVEN,
|
||||
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
"system": system.content,
|
||||
"stream": True,
|
||||
}
|
||||
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
type="enabled", budget_tokens=thinking_budget
|
||||
)
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream, messages),
|
||||
)
|
||||
if not isinstance(content, conversation.AssistantContent)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -1,393 +0,0 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
THINKING_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ToolParam:
|
||||
"""Format tool specification."""
|
||||
return ToolParam(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
)
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
tool_result_block,
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
|
||||
elif isinstance(content, conversation.UserContent):
|
||||
# Combine consequent user messages
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=content.content,
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
TextBlockParam(type="text", text=content.content),
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append( # type: ignore[attr-defined]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
elif isinstance(content, conversation.AssistantContent):
|
||||
# Combine consequent assistant messages
|
||||
if not messages or messages[-1]["role"] != "assistant":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="assistant",
|
||||
content=[],
|
||||
)
|
||||
)
|
||||
|
||||
if content.content:
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
if content.tool_calls:
|
||||
messages[-1]["content"].extend( # type: ignore[union-attr]
|
||||
[
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
messages: list[MessageParam],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- ...
|
||||
- RawContentBlockDeltaEvent with a SignatureDelta
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
|
||||
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_message: MessageParam | None = None
|
||||
current_block: (
|
||||
TextBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ThinkingBlockParam
|
||||
| RedactedThinkingBlockParam
|
||||
| None
|
||||
) = None
|
||||
current_tool_args: str
|
||||
input_usage: Usage | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
current_message = MessageParam(role=response.message.role, content=[])
|
||||
input_usage = response.message.usage
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
current_block = TextBlockParam(
|
||||
type="text", text=response.content_block.text
|
||||
)
|
||||
yield {"role": "assistant"}
|
||||
if response.content_block.text:
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
current_block = ThinkingBlockParam(
|
||||
type="thinking",
|
||||
thinking=response.content_block.thinking,
|
||||
signature=response.content_block.signature,
|
||||
)
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
current_block = RedactedThinkingBlockParam(
|
||||
type="redacted_thinking", data=response.content_block.data
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected delta without a block")
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
text_block = cast(TextBlockParam, current_block)
|
||||
text_block["text"] += response.delta.text
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["thinking"] += response.delta.thinking
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["signature"] += response.delta.signature
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
current_message["content"].append(current_block) # type: ignore[union-attr]
|
||||
current_block = None
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
current_message = None
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AnthropicBaseLLMEntity(Entity):
|
||||
"""Anthropic base LLM entity."""
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools or NOT_GIVEN,
|
||||
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
"system": system.content,
|
||||
"stream": True,
|
||||
}
|
||||
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
type="enabled", budget_tokens=thinking_budget
|
||||
)
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream, messages),
|
||||
)
|
||||
if not isinstance(content, conversation.AssistantContent)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
@@ -71,9 +71,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("message"): str,
|
||||
vol.Optional("media_id"): _media_id_validator,
|
||||
vol.Optional("media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("message", "media_id"),
|
||||
@@ -81,16 +81,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
|
||||
component.async_register_entity_service(
|
||||
"start_conversation",
|
||||
vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): _media_id_validator,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
@@ -136,9 +135,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_domain(DOMAIN),
|
||||
vol.Optional("question"): str,
|
||||
vol.Optional("question_media_id"): _media_id_validator,
|
||||
vol.Optional("question_media_id"): str,
|
||||
vol.Optional("preannounce"): bool,
|
||||
vol.Optional("preannounce_media_id"): _media_id_validator,
|
||||
vol.Optional("preannounce_media_id"): str,
|
||||
vol.Optional("answers"): [
|
||||
{
|
||||
vol.Required("id"): str,
|
||||
@@ -205,20 +204,3 @@ def has_one_non_empty_item(value: list[str]) -> list[str]:
|
||||
raise vol.Invalid("sentences cannot be empty")
|
||||
|
||||
return value
|
||||
|
||||
|
||||
# Validator for media_id fields that accepts both string and media selector format
|
||||
_media_id_validator = vol.Any(
|
||||
cv.string, # Plain string format
|
||||
vol.All(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required("media_content_id"): cv.string,
|
||||
vol.Required("media_content_type"): cv.string,
|
||||
vol.Remove("metadata"): dict, # Ignore metadata if present
|
||||
}
|
||||
),
|
||||
# Extract media_content_id from media selector format
|
||||
lambda x: x["media_content_id"],
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,9 +14,7 @@ announce:
|
||||
media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -25,9 +23,7 @@ announce:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
start_conversation:
|
||||
target:
|
||||
entity:
|
||||
@@ -44,9 +40,7 @@ start_conversation:
|
||||
start_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
extra_system_prompt:
|
||||
required: false
|
||||
selector:
|
||||
@@ -59,9 +53,7 @@ start_conversation:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
ask_question:
|
||||
fields:
|
||||
entity_id:
|
||||
@@ -80,9 +72,7 @@ ask_question:
|
||||
question_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
preannounce:
|
||||
required: false
|
||||
default: true
|
||||
@@ -91,9 +81,7 @@ ask_question:
|
||||
preannounce_media_id:
|
||||
required: false
|
||||
selector:
|
||||
media:
|
||||
accept:
|
||||
- audio/*
|
||||
text:
|
||||
answers:
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery_flow
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -37,6 +37,7 @@ from .manager import (
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
ManagerStateEvent,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
@@ -71,12 +72,12 @@ __all__ = [
|
||||
"IncorrectPasswordError",
|
||||
"LocalBackupAgent",
|
||||
"ManagerBackup",
|
||||
"ManagerStateEvent",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
@@ -103,7 +104,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
backup_manager = BackupManager(hass, reader_writer)
|
||||
hass.data[DATA_MANAGER] = backup_manager
|
||||
await backup_manager.async_setup()
|
||||
try:
|
||||
await backup_manager.async_setup()
|
||||
except Exception as err:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
|
||||
raise
|
||||
else:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_result(None)
|
||||
|
||||
async_register_websocket_handlers(hass, with_hassio)
|
||||
|
||||
@@ -136,15 +143,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_manager(hass: HomeAssistant) -> BackupManager:
|
||||
"""Get the backup manager instance.
|
||||
|
||||
Raises HomeAssistantError if the backup integration is not available.
|
||||
"""
|
||||
if DATA_MANAGER not in hass.data:
|
||||
raise HomeAssistantError("Backup integration is not available")
|
||||
|
||||
return hass.data[DATA_MANAGER]
|
||||
|
||||
38
homeassistant/components/backup/basic_websocket.py
Normal file
38
homeassistant/components/backup/basic_websocket.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Websocket commands for the Backup integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import async_subscribe_events
|
||||
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import ManagerStateEvent
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
|
||||
"""Register websocket commands."""
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
if DATA_MANAGER in hass.data:
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
|
||||
connection.send_result(msg["id"])
|
||||
@@ -8,6 +8,10 @@ from datetime import datetime
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import (
|
||||
async_subscribe_events,
|
||||
async_subscribe_platform_events,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -52,8 +56,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
|
||||
update_interval=None,
|
||||
)
|
||||
self.unsubscribe: list[Callable[[], None]] = [
|
||||
backup_manager.async_subscribe_events(self._on_event),
|
||||
backup_manager.async_subscribe_platform_events(self._on_event),
|
||||
async_subscribe_events(hass, self._on_event),
|
||||
async_subscribe_platform_events(hass, self._on_event),
|
||||
]
|
||||
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
@@ -36,6 +36,7 @@ from homeassistant.helpers import (
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
|
||||
@@ -371,10 +372,12 @@ class BackupManager:
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = BlockedEvent()
|
||||
self.last_action_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
||||
self._backup_platform_event_subscriptions: list[
|
||||
Callable[[BackupPlatformEvent], None]
|
||||
] = []
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_event_subscriptions
|
||||
self._backup_platform_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_platform_event_subscriptions
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the backup manager."""
|
||||
@@ -1382,32 +1385,6 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
@callback
|
||||
def async_subscribe_events(
|
||||
self,
|
||||
on_event: Callable[[ManagerStateEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
@callback
|
||||
def async_subscribe_platform_events(
|
||||
self,
|
||||
on_event: Callable[[BackupPlatformEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to backup platform events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_platform_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_platform_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
def _create_automatic_backup_failed_issue(
|
||||
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
||||
) -> None:
|
||||
|
||||
@@ -19,14 +19,9 @@ from homeassistant.components.onboarding import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
|
||||
from . import (
|
||||
BackupManager,
|
||||
Folder,
|
||||
IncorrectPasswordError,
|
||||
async_get_manager,
|
||||
http as backup_http,
|
||||
)
|
||||
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.onboarding import OnboardingStoreData
|
||||
@@ -59,7 +54,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
|
||||
if self._data["done"]:
|
||||
raise HTTPUnauthorized
|
||||
|
||||
manager = async_get_manager(request.app[KEY_HASS])
|
||||
manager = await async_get_backup_manager(request.app[KEY_HASS])
|
||||
return await func(self, manager, request, *args, **kwargs)
|
||||
|
||||
return with_backup
|
||||
|
||||
@@ -10,11 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .config import Day, ScheduleRecurrence
|
||||
from .const import DATA_MANAGER, LOGGER
|
||||
from .manager import (
|
||||
DecryptOnDowloadNotSupported,
|
||||
IncorrectPasswordError,
|
||||
ManagerStateEvent,
|
||||
)
|
||||
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
|
||||
from .models import BackupNotFound, Folder
|
||||
|
||||
|
||||
@@ -34,7 +30,6 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
||||
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
||||
websocket_api.async_register_command(hass, handle_delete)
|
||||
websocket_api.async_register_command(hass, handle_restore)
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_config_info)
|
||||
websocket_api.async_register_command(hass, handle_config_update)
|
||||
@@ -422,22 +417,3 @@ def handle_config_update(
|
||||
changes.pop("type")
|
||||
manager.config.update(**changes)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,12 +16,7 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
selector,
|
||||
)
|
||||
from homeassistant.helpers.selector import selector
|
||||
|
||||
from .const import (
|
||||
CONF_MESSAGE,
|
||||
@@ -31,9 +26,6 @@ from .const import (
|
||||
FEED_ID,
|
||||
FEED_NAME,
|
||||
FEED_TAG,
|
||||
SYNC_MODE,
|
||||
SYNC_MODE_AUTO,
|
||||
SYNC_MODE_MANUAL,
|
||||
)
|
||||
|
||||
|
||||
@@ -110,17 +102,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"mode": "dropdown",
|
||||
"multiple": True,
|
||||
}
|
||||
if user_input.get(SYNC_MODE) == SYNC_MODE_AUTO:
|
||||
return self.async_create_entry(
|
||||
title=sensor_name(self.url),
|
||||
data={
|
||||
CONF_URL: self.url,
|
||||
CONF_API_KEY: self.api_key,
|
||||
CONF_ONLY_INCLUDE_FEEDID: [
|
||||
feed[FEED_ID] for feed in result[CONF_MESSAGE]
|
||||
],
|
||||
},
|
||||
)
|
||||
return await self.async_step_choose_feeds()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -129,15 +110,6 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(
|
||||
SYNC_MODE, default=SYNC_MODE_MANUAL
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[SYNC_MODE_MANUAL, SYNC_MODE_AUTO],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=SYNC_MODE,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
user_input,
|
||||
|
||||
@@ -14,9 +14,6 @@ EMONCMS_UUID_DOC_URL = (
|
||||
FEED_ID = "id"
|
||||
FEED_NAME = "name"
|
||||
FEED_TAG = "tag"
|
||||
SYNC_MODE = "sync_mode"
|
||||
SYNC_MODE_AUTO = "auto"
|
||||
SYNC_MODE_MANUAL = "manual"
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"sync_mode": "Synchronization mode"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "Server URL starting with the protocol (http or https)",
|
||||
@@ -25,14 +24,6 @@
|
||||
"already_configured": "This server is already configured"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"sync_mode": {
|
||||
"options": {
|
||||
"auto": "Synchronize all available Feeds",
|
||||
"manual": "Select which Feeds to synchronize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"energy": {
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"full": "[%key:common::state::full%]"
|
||||
"full": "Full"
|
||||
}
|
||||
},
|
||||
"acb_available_energy": {
|
||||
|
||||
@@ -81,7 +81,6 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
# if the string is empty
|
||||
if unit_of_measurement := static_info.unit_of_measurement:
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
self._attr_suggested_display_precision = static_info.accuracy_decimals
|
||||
self._attr_device_class = try_parse_enum(
|
||||
SensorDeviceClass, static_info.device_class
|
||||
)
|
||||
@@ -98,7 +97,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
self._attr_state_class = _STATE_CLASSES.from_esphome(state_class)
|
||||
|
||||
@property
|
||||
def native_value(self) -> datetime | int | float | None:
|
||||
def native_value(self) -> datetime | str | None:
|
||||
"""Return the state of the entity."""
|
||||
if not self._has_state or (state := self._state).missing_state:
|
||||
return None
|
||||
@@ -107,7 +106,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity):
|
||||
return None
|
||||
if self.device_class is SensorDeviceClass.TIMESTAMP:
|
||||
return dt_util.utc_from_timestamp(state_float)
|
||||
return state_float
|
||||
return f"{state_float:.{self._static_info.accuracy_decimals}f}"
|
||||
|
||||
|
||||
class EsphomeTextSensor(EsphomeEntity[TextSensorInfo, TextSensorState], SensorEntity):
|
||||
|
||||
@@ -19,7 +19,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="chlorine",
|
||||
translation_key="chlorine",
|
||||
native_unit_of_measurement="mg/L",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
||||
@@ -207,8 +207,6 @@ async def async_setup_entry(
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -222,13 +220,6 @@ async def async_unload_entry(
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -308,50 +299,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
|
||||
|
||||
@@ -61,6 +61,9 @@ class GoogleGenerativeAIConversationEntity(
|
||||
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."""
|
||||
@@ -100,3 +103,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -48,13 +48,13 @@ from homeassistant.components.backup import (
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
async_get_manager as async_get_backup_manager,
|
||||
suggested_filename as suggested_backup_filename,
|
||||
suggested_filename_from_name_date,
|
||||
)
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
@@ -839,7 +839,7 @@ async def backup_addon_before_update(
|
||||
|
||||
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
||||
"""Prepare for updating core."""
|
||||
backup_manager = async_get_backup_manager(hass)
|
||||
backup_manager = await async_get_backup_manager(hass)
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
|
||||
@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import services
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeosConfigEntry, HeosCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
@@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HEOS component."""
|
||||
async_setup_services(hass)
|
||||
services.register(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
@@ -44,8 +44,7 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
|
||||
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register(hass: HomeAssistant) -> None:
|
||||
"""Register HEOS services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
|
||||
from .services import async_setup_services
|
||||
from .services import register_actions
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +43,7 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Home Connect component."""
|
||||
async_setup_services(hass)
|
||||
register_actions(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
@@ -522,8 +522,7 @@ async def async_service_start_program(call: ServiceCall) -> None:
|
||||
await _async_service_program(call, True)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_actions(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -27,7 +27,6 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
@@ -68,7 +67,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self.addon_start_task: asyncio.Task | None = None
|
||||
self.addon_uninstall_task: asyncio.Task | None = None
|
||||
self.firmware_install_task: asyncio.Task | None = None
|
||||
self.installing_firmware_name: str | None = None
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
"""Shared translation placeholders."""
|
||||
@@ -154,12 +152,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
assert self._device is not None
|
||||
|
||||
if not self.firmware_install_task:
|
||||
# Keep track of the firmware we're working with, for error messages
|
||||
self.installing_firmware_name = firmware_name
|
||||
|
||||
# Installing new firmware is only truly required if the wrong type is
|
||||
# installed: upgrading to the latest release of the current firmware type
|
||||
# isn't strictly necessary for functionality.
|
||||
# We 100% need to install new firmware only if the wrong firmware is
|
||||
# currently installed
|
||||
firmware_install_required = self._probed_firmware_info is None or (
|
||||
self._probed_firmware_info.firmware_type
|
||||
!= expected_installed_firmware_type
|
||||
@@ -173,7 +167,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
fw_manifest = next(
|
||||
fw for fw in manifest.firmwares if fw.filename.startswith(fw_type)
|
||||
)
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing):
|
||||
except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err:
|
||||
_LOGGER.warning(
|
||||
"Failed to fetch firmware update manifest", exc_info=True
|
||||
)
|
||||
@@ -185,9 +179,13 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
raise AbortFlow(
|
||||
"fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
@@ -207,7 +205,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
try:
|
||||
fw_data = await client.async_fetch_firmware(fw_manifest)
|
||||
except (TimeoutError, ClientError, ValueError):
|
||||
except (TimeoutError, ClientError, ValueError) as err:
|
||||
_LOGGER.warning("Failed to fetch firmware update", exc_info=True)
|
||||
|
||||
# If we cannot download new firmware, we shouldn't block setup
|
||||
@@ -218,9 +216,13 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
# Otherwise, fail
|
||||
return self.async_show_progress_done(
|
||||
next_step_id="firmware_download_failed"
|
||||
)
|
||||
raise AbortFlow(
|
||||
"fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
) from err
|
||||
|
||||
self.firmware_install_task = self.hass.async_create_task(
|
||||
async_flash_silabs_firmware(
|
||||
@@ -247,40 +249,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
progress_task=self.firmware_install_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.firmware_install_task
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception("Failed to flash firmware")
|
||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
||||
|
||||
return self.async_show_progress_done(next_step_id=next_step_id)
|
||||
|
||||
async def async_step_firmware_download_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when firmware download failed."""
|
||||
assert self.installing_firmware_name is not None
|
||||
return self.async_abort(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": self.installing_firmware_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_firmware_install_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when firmware install failed."""
|
||||
assert self.installing_firmware_name is not None
|
||||
return self.async_abort(
|
||||
reason="fw_install_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": self.installing_firmware_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_pick_firmware_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -37,8 +37,7 @@
|
||||
"zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.",
|
||||
"otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.",
|
||||
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again.",
|
||||
"fw_install_failed": "{firmware_name} firmware failed to install, check Home Assistant logs for more information."
|
||||
"fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again."
|
||||
},
|
||||
"progress": {
|
||||
"install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes."
|
||||
|
||||
@@ -93,8 +93,7 @@
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
@@ -148,8 +147,7 @@
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]",
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
|
||||
@@ -118,8 +118,7 @@
|
||||
"zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]",
|
||||
"otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]",
|
||||
"unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.",
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]",
|
||||
"fw_install_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_install_failed%]"
|
||||
"fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]"
|
||||
},
|
||||
"progress": {
|
||||
"install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]",
|
||||
|
||||
@@ -9,9 +9,17 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_create_clientsession,
|
||||
async_get_clientsession,
|
||||
)
|
||||
|
||||
from .const import _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE
|
||||
from .const import (
|
||||
_LOGGER,
|
||||
CONF_COOL_AWAY_TEMPERATURE,
|
||||
CONF_HEAT_AWAY_TEMPERATURE,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
UPDATE_LOOP_SLEEP_TIME = 5
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.HUMIDIFIER, Platform.SENSOR, Platform.SWITCH]
|
||||
@@ -48,11 +56,11 @@ async def async_setup_entry(
|
||||
username = config_entry.data[CONF_USERNAME]
|
||||
password = config_entry.data[CONF_PASSWORD]
|
||||
|
||||
# Always create a new session for Honeywell to prevent cookie injection
|
||||
# issues. Even with response_url handling in aiosomecomfort 0.0.33+,
|
||||
# cookies can still leak into other integrations when using the shared
|
||||
# session. See issue #147395.
|
||||
session = async_create_clientsession(hass)
|
||||
if len(hass.config_entries.async_entries(DOMAIN)) > 1:
|
||||
session = async_create_clientsession(hass)
|
||||
else:
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
client = aiosomecomfort.AIOSomeComfort(username, password, session=session)
|
||||
try:
|
||||
await client.login()
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.config_entries import (
|
||||
)
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_COOL_AWAY_TEMPERATURE,
|
||||
@@ -114,14 +114,10 @@ class HoneywellConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def is_valid(self, **kwargs) -> bool:
|
||||
"""Check if login credentials are valid."""
|
||||
# Always create a new session for Honeywell to prevent cookie injection
|
||||
# issues. Even with response_url handling in aiosomecomfort 0.0.33+,
|
||||
# cookies can still leak into other integrations when using the shared
|
||||
# session. See issue #147395.
|
||||
client = aiosomecomfort.AIOSomeComfort(
|
||||
kwargs[CONF_USERNAME],
|
||||
kwargs[CONF_PASSWORD],
|
||||
session=async_create_clientsession(self.hass),
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
|
||||
await client.login()
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -10,8 +10,4 @@ OHM = "Ω"
|
||||
DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533"
|
||||
|
||||
MAX_TEMP: int = 450
|
||||
MAX_TEMP_F: int = 850
|
||||
MIN_TEMP: int = 10
|
||||
MIN_TEMP_F: int = 50
|
||||
MIN_BOOST_TEMP: int = 250
|
||||
MIN_BOOST_TEMP_F: int = 480
|
||||
|
||||
@@ -168,9 +168,7 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
|
||||
|
||||
if self.device.is_connected and characteristics:
|
||||
try:
|
||||
return await self.device.get_settings(
|
||||
list(characteristics | {CharSetting.TEMP_UNIT})
|
||||
)
|
||||
return await self.device.get_settings(list(characteristics))
|
||||
except CommunicationError as e:
|
||||
_LOGGER.debug("Failed to fetch settings", exc_info=e)
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit
|
||||
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse
|
||||
|
||||
from homeassistant.components.number import (
|
||||
DEFAULT_MAX_VALUE,
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
@@ -23,17 +24,9 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from . import IronOSConfigEntry
|
||||
from .const import (
|
||||
MAX_TEMP,
|
||||
MAX_TEMP_F,
|
||||
MIN_BOOST_TEMP,
|
||||
MIN_BOOST_TEMP_F,
|
||||
MIN_TEMP,
|
||||
MIN_TEMP_F,
|
||||
)
|
||||
from .const import MAX_TEMP, MIN_TEMP
|
||||
from .coordinator import IronOSCoordinators
|
||||
from .entity import IronOSBaseEntity
|
||||
|
||||
@@ -45,10 +38,9 @@ class IronOSNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes IronOS number entity."""
|
||||
|
||||
value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None]
|
||||
max_value_fn: Callable[[LiveDataResponse], float | int] | None = None
|
||||
characteristic: CharSetting
|
||||
raw_value_fn: Callable[[float], float | int] | None = None
|
||||
native_max_value_f: float | None = None
|
||||
native_min_value_f: float | None = None
|
||||
|
||||
|
||||
class PinecilNumber(StrEnum):
|
||||
@@ -82,6 +74,44 @@ def multiply(value: float | None, multiplier: float) -> float | None:
|
||||
|
||||
|
||||
PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SETPOINT_TEMP,
|
||||
translation_key=PinecilNumber.SETPOINT_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda data, _: data.setpoint_temp,
|
||||
characteristic=CharSetting.SETPOINT_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_step=5,
|
||||
max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP),
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SLEEP_TEMP,
|
||||
translation_key=PinecilNumber.SLEEP_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda _, settings: settings.get("sleep_temp"),
|
||||
characteristic=CharSetting.SLEEP_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.BOOST_TEMP,
|
||||
translation_key=PinecilNumber.BOOST_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda _, settings: settings.get("boost_temp"),
|
||||
characteristic=CharSetting.BOOST_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.QC_MAX_VOLTAGE,
|
||||
translation_key=PinecilNumber.QC_MAX_VOLTAGE,
|
||||
@@ -266,6 +296,32 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_short")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_SHORT,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=1,
|
||||
native_max_value=50,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_long")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_LONG,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=5,
|
||||
native_max_value=90,
|
||||
native_step=5,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
)
|
||||
|
||||
PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
@@ -285,82 +341,6 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
"""
|
||||
The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities.
|
||||
These entities represent user-defined input values, not measured temperatures, and their
|
||||
interpretation depends on the device's current unit configuration. Applying a device_class
|
||||
results in automatic unit conversions, which introduce rounding errors due to the use of integers.
|
||||
This can prevent the correct value from being set, as the input is modified during synchronization with the device.
|
||||
"""
|
||||
PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SLEEP_TEMP,
|
||||
translation_key=PinecilNumber.SLEEP_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda _, settings: settings.get("sleep_temp"),
|
||||
characteristic=CharSetting.SLEEP_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_min_value_f=MIN_TEMP_F,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.BOOST_TEMP,
|
||||
translation_key=PinecilNumber.BOOST_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda _, settings: settings.get("boost_temp"),
|
||||
characteristic=CharSetting.BOOST_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_BOOST_TEMP,
|
||||
native_min_value_f=MIN_BOOST_TEMP_F,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_short")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_SHORT,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=1,
|
||||
native_max_value=50,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_long")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_LONG,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=5,
|
||||
native_max_value=90,
|
||||
native_step=5,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SETPOINT_TEMP,
|
||||
translation_key=PinecilNumber.SETPOINT_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data, _: data.setpoint_temp,
|
||||
characteristic=CharSetting.SETPOINT_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_min_value_f=MIN_TEMP_F,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=5,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -374,18 +354,9 @@ async def async_setup_entry(
|
||||
if coordinators.live_data.v223_features:
|
||||
descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223
|
||||
|
||||
entities = [
|
||||
async_add_entities(
|
||||
IronOSNumberEntity(coordinators, description) for description in descriptions
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
IronOSTemperatureNumberEntity(coordinators, description)
|
||||
for description in PINECIL_TEMP_NUMBER_DESCRIPTIONS
|
||||
)
|
||||
entities.append(
|
||||
IronOSSetpointNumberEntity(coordinators, PINECIL_SETPOINT_NUMBER_DESCRIPTION)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
|
||||
@@ -417,6 +388,15 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
|
||||
self.coordinator.data, self.settings.data
|
||||
)
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return sensor state."""
|
||||
|
||||
if self.entity_description.max_value_fn is not None:
|
||||
return self.entity_description.max_value_fn(self.coordinator.data)
|
||||
|
||||
return self.entity_description.native_max_value or DEFAULT_MAX_VALUE
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
|
||||
@@ -427,60 +407,3 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
|
||||
)
|
||||
)
|
||||
await self.settings.async_request_refresh()
|
||||
|
||||
|
||||
class IronOSTemperatureNumberEntity(IronOSNumberEntity):
|
||||
"""Implementation of a IronOS temperature number entity."""
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement of the sensor, if any."""
|
||||
|
||||
return (
|
||||
UnitOfTemperature.FAHRENHEIT
|
||||
if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT
|
||||
else UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return the minimum value."""
|
||||
|
||||
return (
|
||||
self.entity_description.native_min_value_f
|
||||
if self.entity_description.native_min_value_f
|
||||
and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT
|
||||
else super().native_min_value
|
||||
)
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
|
||||
return (
|
||||
self.entity_description.native_max_value_f
|
||||
if self.entity_description.native_max_value_f
|
||||
and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT
|
||||
else super().native_max_value
|
||||
)
|
||||
|
||||
|
||||
class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity):
|
||||
"""IronOS setpoint temperature entity."""
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
|
||||
return (
|
||||
min(
|
||||
TemperatureConverter.convert(
|
||||
float(max_tip_c),
|
||||
UnitOfTemperature.CELSIUS,
|
||||
self.native_unit_of_measurement,
|
||||
),
|
||||
super().native_max_value,
|
||||
)
|
||||
if (max_tip_c := self.coordinator.data.max_tip_temp_ability) is not None
|
||||
else super().native_max_value
|
||||
)
|
||||
|
||||
@@ -108,22 +108,22 @@ def get_statistics(
|
||||
if monthly_consumptions := get_consumptions(data, value_type):
|
||||
return [
|
||||
{
|
||||
"value": as_number(value),
|
||||
"value": as_number(
|
||||
get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
).get(
|
||||
"additionalValue"
|
||||
if value_type == IstaValueType.ENERGY
|
||||
else "value"
|
||||
)
|
||||
),
|
||||
"date": consumptions["date"],
|
||||
}
|
||||
for consumptions in monthly_consumptions
|
||||
if (
|
||||
value := (
|
||||
consumption := get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
)
|
||||
).get(
|
||||
"additionalValue"
|
||||
if value_type == IstaValueType.ENERGY
|
||||
and consumption.get("additionalValue") is not None
|
||||
else "value"
|
||||
)
|
||||
)
|
||||
if get_values_by_type(
|
||||
consumptions=consumptions,
|
||||
consumption_type=consumption_type,
|
||||
).get("additionalValue" if value_type == IstaValueType.ENERGY else "value")
|
||||
]
|
||||
return None
|
||||
|
||||
@@ -91,7 +91,7 @@ from .schema import (
|
||||
TimeSchema,
|
||||
WeatherSchema,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
from .services import register_knx_services
|
||||
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore
|
||||
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
|
||||
from .websocket import register_panel
|
||||
@@ -138,7 +138,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if (conf := config.get(DOMAIN)) is not None:
|
||||
hass.data[_KNX_YAML_CONFIG] = dict(conf)
|
||||
|
||||
async_setup_services(hass)
|
||||
register_knx_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_knx_services(hass: HomeAssistant) -> None:
|
||||
"""Register KNX integration services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pypck==0.8.10", "lcn-frontend==0.2.5"]
|
||||
"requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"]
|
||||
}
|
||||
|
||||
@@ -780,10 +780,10 @@
|
||||
"battery_level": {
|
||||
"name": "Battery",
|
||||
"state": {
|
||||
"high": "[%key:common::state::full%]",
|
||||
"high": "Full",
|
||||
"mid": "[%key:common::state::medium%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"warning": "[%key:common::state::empty%]"
|
||||
"warning": "Empty"
|
||||
}
|
||||
},
|
||||
"relative_to_start": {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"motor_fault_short": "Motor shorted",
|
||||
"motor_ot_amps": "Motor overtorqued",
|
||||
"motor_disconnected": "Motor disconnected",
|
||||
"empty": "[%key:common::state::empty%]"
|
||||
"empty": "Empty"
|
||||
}
|
||||
},
|
||||
"last_seen": {
|
||||
|
||||
@@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.json import JsonObjectType, load_json_object
|
||||
|
||||
from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML
|
||||
from .services import async_setup_services
|
||||
from .services import register_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
config[CONF_COMMANDS],
|
||||
)
|
||||
|
||||
async_setup_services(hass)
|
||||
register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
@@ -50,8 +50,7 @@ async def _handle_send_message(call: ServiceCall) -> None:
|
||||
await matrix_bot.handle_send_message(call)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def register_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Matrix bot component."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -172,7 +172,7 @@ async def async_modbus_setup(
|
||||
|
||||
async def async_write_register(service: ServiceCall) -> None:
|
||||
"""Write Modbus registers."""
|
||||
slave = 1
|
||||
slave = 0
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
|
||||
@@ -195,7 +195,7 @@ async def async_modbus_setup(
|
||||
|
||||
async def async_write_coil(service: ServiceCall) -> None:
|
||||
"""Write Modbus coil."""
|
||||
slave = 1
|
||||
slave = 0
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
if ATTR_SLAVE in service.data:
|
||||
|
||||
@@ -1904,12 +1904,8 @@ ENTITY_CONFIG_VALIDATOR: dict[
|
||||
|
||||
MQTT_DEVICE_PLATFORM_FIELDS = {
|
||||
ATTR_NAME: PlatformField(selector=TEXT_SELECTOR, required=True),
|
||||
ATTR_SW_VERSION: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
|
||||
),
|
||||
ATTR_HW_VERSION: PlatformField(
|
||||
selector=TEXT_SELECTOR, required=False, section="advanced_settings"
|
||||
),
|
||||
ATTR_SW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_HW_VERSION: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_MODEL: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_MODEL_ID: PlatformField(selector=TEXT_SELECTOR, required=False),
|
||||
ATTR_CONFIGURATION_URL: PlatformField(
|
||||
@@ -2729,19 +2725,6 @@ class MQTTSubentryFlowHandler(ConfigSubentryFlow):
|
||||
for field_key, value in data_schema.schema.items()
|
||||
}
|
||||
|
||||
@callback
|
||||
def get_suggested_values_from_device_data(
|
||||
self, data_schema: vol.Schema
|
||||
) -> dict[str, Any]:
|
||||
"""Get suggestions from device data based on the data schema."""
|
||||
device_data = self._subentry_data["device"]
|
||||
return {
|
||||
field_key: self.get_suggested_values_from_device_data(value.schema)
|
||||
if isinstance(value, section)
|
||||
else device_data.get(field_key)
|
||||
for field_key, value in data_schema.schema.items()
|
||||
}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
@@ -2771,24 +2754,15 @@ 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")
|
||||
if not errors:
|
||||
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, new_device_data)
|
||||
self._subentry_data[CONF_DEVICE] = cast(MqttDeviceData, user_input)
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return await self.async_step_summary_menu()
|
||||
return await self.async_step_entity()
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema, device_data if user_input is None else user_input
|
||||
)
|
||||
elif self.source == SOURCE_RECONFIGURE:
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema,
|
||||
self.get_suggested_values_from_device_data(data_schema),
|
||||
)
|
||||
|
||||
data_schema = self.add_suggested_values_to_schema(
|
||||
data_schema, device_data if user_input is None else user_input
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id=CONF_DEVICE,
|
||||
data_schema=data_schema,
|
||||
|
||||
@@ -134,27 +134,20 @@
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"configuration_url": "Configuration URL",
|
||||
"sw_version": "Software version",
|
||||
"hw_version": "Hardware version",
|
||||
"model": "Model",
|
||||
"model_id": "Model ID"
|
||||
},
|
||||
"data_description": {
|
||||
"name": "The name of the manually added MQTT device.",
|
||||
"configuration_url": "A link to the webpage that can manage the configuration of this device. Can be either a 'http://', 'https://' or an internal 'homeassistant://' URL.",
|
||||
"sw_version": "The software version of the device. E.g. '2025.1.0'.",
|
||||
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'.",
|
||||
"model": "E.g. 'Cleanmaster Pro'.",
|
||||
"model_id": "E.g. '123NK2PRO'."
|
||||
},
|
||||
"sections": {
|
||||
"advanced_settings": {
|
||||
"name": "Advanced device settings",
|
||||
"data": {
|
||||
"sw_version": "Software version",
|
||||
"hw_version": "Hardware version"
|
||||
},
|
||||
"data_description": {
|
||||
"sw_version": "The software version of the device. E.g. '2025.1.0'.",
|
||||
"hw_version": "The hardware version of the device. E.g. 'v1.0 rev a'."
|
||||
}
|
||||
},
|
||||
"mqtt_settings": {
|
||||
"name": "MQTT settings",
|
||||
"data": {
|
||||
|
||||
@@ -69,9 +69,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bo
|
||||
|
||||
entry.runtime_data = client
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -82,11 +79,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -156,34 +148,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
_LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -73,7 +73,6 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Ollama."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize config flow."""
|
||||
|
||||
@@ -2,18 +2,41 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
import ollama
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import OllamaBaseLLMEntity
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_PROMPT,
|
||||
CONF_THINK,
|
||||
DEFAULT_KEEP_ALIVE,
|
||||
DEFAULT_MAX_HISTORY,
|
||||
DEFAULT_NUM_CTX,
|
||||
DOMAIN,
|
||||
)
|
||||
from .models import MessageHistory, MessageRole
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -32,10 +55,129 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""Format tool specification."""
|
||||
tool_spec = {
|
||||
"name": tool.name,
|
||||
"parameters": convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
}
|
||||
if tool.description:
|
||||
tool_spec["description"] = tool.description
|
||||
return {"type": "function", "function": tool_spec}
|
||||
|
||||
|
||||
def _fix_invalid_arguments(value: Any) -> Any:
|
||||
"""Attempt to repair incorrectly formatted json function arguments.
|
||||
|
||||
Small models (for example llama3.1 8B) may produce invalid argument values
|
||||
which we attempt to repair here.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if (value.startswith("[") and value.endswith("]")) or (
|
||||
value.startswith("{") and value.endswith("}")
|
||||
):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Rewrite ollama tool arguments.
|
||||
|
||||
This function improves tool use quality by fixing common mistakes made by
|
||||
small local tool use models. This will repair invalid json arguments and
|
||||
omit unnecessary arguments with empty values that will fail intent parsing.
|
||||
"""
|
||||
return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v}
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: (
|
||||
conversation.Content
|
||||
| conversation.ToolResultContent
|
||||
| conversation.AssistantContent
|
||||
),
|
||||
) -> ollama.Message:
|
||||
"""Create tool response content."""
|
||||
if isinstance(chat_content, conversation.ToolResultContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.TOOL.value,
|
||||
content=json.dumps(chat_content.tool_result),
|
||||
)
|
||||
if isinstance(chat_content, conversation.AssistantContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.ASSISTANT.value,
|
||||
content=chat_content.content,
|
||||
tool_calls=[
|
||||
ollama.Message.ToolCall(
|
||||
function=ollama.Message.ToolCall.Function(
|
||||
name=tool_call.tool_name,
|
||||
arguments=tool_call.tool_args,
|
||||
)
|
||||
)
|
||||
for tool_call in chat_content.tool_calls or ()
|
||||
],
|
||||
)
|
||||
if isinstance(chat_content, conversation.UserContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.USER.value,
|
||||
content=chat_content.content,
|
||||
)
|
||||
if isinstance(chat_content, conversation.SystemContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.SYSTEM.value,
|
||||
content=chat_content.content,
|
||||
)
|
||||
raise TypeError(f"Unexpected content type: {type(chat_content)}")
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
result: AsyncIterator[ollama.ChatResponse],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
An Ollama streaming response may come in chunks like this:
|
||||
|
||||
response: message=Message(role="assistant", content="Paris")
|
||||
response: message=Message(role="assistant", content=".")
|
||||
response: message=Message(role="assistant", content=""), done: True, done_reason: "stop"
|
||||
response: message=Message(role="assistant", tool_calls=[...])
|
||||
response: message=Message(role="assistant", content=""), done: True, done_reason: "stop"
|
||||
|
||||
This generator conforms to the chatlog delta stream expectations in that it
|
||||
yields deltas, then the role only once the response is done.
|
||||
"""
|
||||
|
||||
new_msg = True
|
||||
async for response in result:
|
||||
_LOGGER.debug("Received response: %s", response)
|
||||
response_message = response["message"]
|
||||
chunk: conversation.AssistantContentDeltaDict = {}
|
||||
if new_msg:
|
||||
new_msg = False
|
||||
chunk["role"] = "assistant"
|
||||
if (tool_calls := response_message.get("tool_calls")) is not None:
|
||||
chunk["tool_calls"] = [
|
||||
llm.ToolInput(
|
||||
tool_name=tool_call["function"]["name"],
|
||||
tool_args=_parse_tool_args(tool_call["function"]["arguments"]),
|
||||
)
|
||||
for tool_call in tool_calls
|
||||
]
|
||||
if (content := response_message.get("content")) is not None:
|
||||
chunk["content"] = content
|
||||
if response_message.get("done"):
|
||||
new_msg = True
|
||||
yield chunk
|
||||
|
||||
|
||||
class OllamaConversationEntity(
|
||||
conversation.ConversationEntity,
|
||||
conversation.AbstractConversationAgent,
|
||||
OllamaBaseLLMEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""Ollama conversation agent."""
|
||||
|
||||
@@ -43,7 +185,17 @@ class OllamaConversationEntity(
|
||||
|
||||
def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Ollama",
|
||||
model=entry.data[CONF_MODEL],
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -56,6 +208,9 @@ class OllamaConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -99,3 +254,93 @@ class OllamaConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
settings = {**self.entry.data, **self.subentry.data}
|
||||
|
||||
client = self.entry.runtime_data
|
||||
model = settings[CONF_MODEL]
|
||||
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
message_history: MessageHistory = MessageHistory(
|
||||
[_convert_content(content) for content in chat_log.content]
|
||||
)
|
||||
max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY))
|
||||
self._trim_history(message_history, max_messages)
|
||||
|
||||
# Get response
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
response_generator = await client.chat(
|
||||
model=model,
|
||||
# Make a copy of the messages because we mutate the list later
|
||||
messages=list(message_history.messages),
|
||||
tools=tools,
|
||||
stream=True,
|
||||
# keep_alive requires specifying unit. In this case, seconds
|
||||
keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s",
|
||||
options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
|
||||
think=settings.get(CONF_THINK),
|
||||
)
|
||||
except (ollama.RequestError, ollama.ResponseError) as err:
|
||||
_LOGGER.error("Unexpected error talking to Ollama server: %s", err)
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to the Ollama server: {err}"
|
||||
) from err
|
||||
|
||||
message_history.messages.extend(
|
||||
[
|
||||
_convert_content(content)
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_stream(response_generator)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None:
|
||||
"""Trims excess messages from a single history.
|
||||
|
||||
This sets the max history to allow a configurable size history may take
|
||||
up in the context window.
|
||||
|
||||
Note that some messages in the history may not be from ollama only, and
|
||||
may come from other anents, so the assumptions here may not strictly hold,
|
||||
but generally should be effective.
|
||||
"""
|
||||
if max_messages < 1:
|
||||
# Keep all messages
|
||||
return
|
||||
|
||||
# Ignore the in progress user message
|
||||
num_previous_rounds = message_history.num_user_messages - 1
|
||||
if num_previous_rounds >= max_messages:
|
||||
# Trim history but keep system prompt (first message).
|
||||
# Every other message should be an assistant message, so keep 2x
|
||||
# message objects. Also keep the last in progress user message
|
||||
num_keep = 2 * max_messages + 1
|
||||
drop_index = len(message_history.messages) - num_keep
|
||||
message_history.messages = [
|
||||
message_history.messages[0],
|
||||
*message_history.messages[drop_index:],
|
||||
]
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
"""Base entity for the Ollama integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, AsyncIterator, Callable
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import ollama
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import OllamaConfigEntry
|
||||
from .const import (
|
||||
CONF_KEEP_ALIVE,
|
||||
CONF_MAX_HISTORY,
|
||||
CONF_MODEL,
|
||||
CONF_NUM_CTX,
|
||||
CONF_THINK,
|
||||
DEFAULT_KEEP_ALIVE,
|
||||
DEFAULT_MAX_HISTORY,
|
||||
DEFAULT_NUM_CTX,
|
||||
DOMAIN,
|
||||
)
|
||||
from .models import MessageHistory, MessageRole
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> dict[str, Any]:
|
||||
"""Format tool specification."""
|
||||
tool_spec = {
|
||||
"name": tool.name,
|
||||
"parameters": convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
}
|
||||
if tool.description:
|
||||
tool_spec["description"] = tool.description
|
||||
return {"type": "function", "function": tool_spec}
|
||||
|
||||
|
||||
def _fix_invalid_arguments(value: Any) -> Any:
|
||||
"""Attempt to repair incorrectly formatted json function arguments.
|
||||
|
||||
Small models (for example llama3.1 8B) may produce invalid argument values
|
||||
which we attempt to repair here.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
if (value.startswith("[") and value.endswith("]")) or (
|
||||
value.startswith("{") and value.endswith("}")
|
||||
):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.decoder.JSONDecodeError:
|
||||
pass
|
||||
return value
|
||||
|
||||
|
||||
def _parse_tool_args(arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Rewrite ollama tool arguments.
|
||||
|
||||
This function improves tool use quality by fixing common mistakes made by
|
||||
small local tool use models. This will repair invalid json arguments and
|
||||
omit unnecessary arguments with empty values that will fail intent parsing.
|
||||
"""
|
||||
return {k: _fix_invalid_arguments(v) for k, v in arguments.items() if v}
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: (
|
||||
conversation.Content
|
||||
| conversation.ToolResultContent
|
||||
| conversation.AssistantContent
|
||||
),
|
||||
) -> ollama.Message:
|
||||
"""Create tool response content."""
|
||||
if isinstance(chat_content, conversation.ToolResultContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.TOOL.value,
|
||||
content=json.dumps(chat_content.tool_result),
|
||||
)
|
||||
if isinstance(chat_content, conversation.AssistantContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.ASSISTANT.value,
|
||||
content=chat_content.content,
|
||||
tool_calls=[
|
||||
ollama.Message.ToolCall(
|
||||
function=ollama.Message.ToolCall.Function(
|
||||
name=tool_call.tool_name,
|
||||
arguments=tool_call.tool_args,
|
||||
)
|
||||
)
|
||||
for tool_call in chat_content.tool_calls or ()
|
||||
],
|
||||
)
|
||||
if isinstance(chat_content, conversation.UserContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.USER.value,
|
||||
content=chat_content.content,
|
||||
)
|
||||
if isinstance(chat_content, conversation.SystemContent):
|
||||
return ollama.Message(
|
||||
role=MessageRole.SYSTEM.value,
|
||||
content=chat_content.content,
|
||||
)
|
||||
raise TypeError(f"Unexpected content type: {type(chat_content)}")
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
result: AsyncIterator[ollama.ChatResponse],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
An Ollama streaming response may come in chunks like this:
|
||||
|
||||
response: message=Message(role="assistant", content="Paris")
|
||||
response: message=Message(role="assistant", content=".")
|
||||
response: message=Message(role="assistant", content=""), done: True, done_reason: "stop"
|
||||
response: message=Message(role="assistant", tool_calls=[...])
|
||||
response: message=Message(role="assistant", content=""), done: True, done_reason: "stop"
|
||||
|
||||
This generator conforms to the chatlog delta stream expectations in that it
|
||||
yields deltas, then the role only once the response is done.
|
||||
"""
|
||||
|
||||
new_msg = True
|
||||
async for response in result:
|
||||
_LOGGER.debug("Received response: %s", response)
|
||||
response_message = response["message"]
|
||||
chunk: conversation.AssistantContentDeltaDict = {}
|
||||
if new_msg:
|
||||
new_msg = False
|
||||
chunk["role"] = "assistant"
|
||||
if (tool_calls := response_message.get("tool_calls")) is not None:
|
||||
chunk["tool_calls"] = [
|
||||
llm.ToolInput(
|
||||
tool_name=tool_call["function"]["name"],
|
||||
tool_args=_parse_tool_args(tool_call["function"]["arguments"]),
|
||||
)
|
||||
for tool_call in tool_calls
|
||||
]
|
||||
if (content := response_message.get("content")) is not None:
|
||||
chunk["content"] = content
|
||||
if response_message.get("done"):
|
||||
new_msg = True
|
||||
yield chunk
|
||||
|
||||
|
||||
class OllamaBaseLLMEntity(Entity):
|
||||
"""Ollama base LLM entity."""
|
||||
|
||||
def __init__(self, entry: OllamaConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Ollama",
|
||||
model=entry.data[CONF_MODEL],
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
settings = {**self.entry.data, **self.subentry.data}
|
||||
|
||||
client = self.entry.runtime_data
|
||||
model = settings[CONF_MODEL]
|
||||
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
message_history: MessageHistory = MessageHistory(
|
||||
[_convert_content(content) for content in chat_log.content]
|
||||
)
|
||||
max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY))
|
||||
self._trim_history(message_history, max_messages)
|
||||
|
||||
# Get response
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
response_generator = await client.chat(
|
||||
model=model,
|
||||
# Make a copy of the messages because we mutate the list later
|
||||
messages=list(message_history.messages),
|
||||
tools=tools,
|
||||
stream=True,
|
||||
# keep_alive requires specifying unit. In this case, seconds
|
||||
keep_alive=f"{settings.get(CONF_KEEP_ALIVE, DEFAULT_KEEP_ALIVE)}s",
|
||||
options={CONF_NUM_CTX: settings.get(CONF_NUM_CTX, DEFAULT_NUM_CTX)},
|
||||
think=settings.get(CONF_THINK),
|
||||
)
|
||||
except (ollama.RequestError, ollama.ResponseError) as err:
|
||||
_LOGGER.error("Unexpected error talking to Ollama server: %s", err)
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to the Ollama server: {err}"
|
||||
) from err
|
||||
|
||||
message_history.messages.extend(
|
||||
[
|
||||
_convert_content(content)
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_stream(response_generator)
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None:
|
||||
"""Trims excess messages from a single history.
|
||||
|
||||
This sets the max history to allow a configurable size history may take
|
||||
up in the context window.
|
||||
|
||||
Note that some messages in the history may not be from ollama only, and
|
||||
may come from other anents, so the assumptions here may not strictly hold,
|
||||
but generally should be effective.
|
||||
"""
|
||||
if max_messages < 1:
|
||||
# Keep all messages
|
||||
return
|
||||
|
||||
# Ignore the in progress user message
|
||||
num_previous_rounds = message_history.num_user_messages - 1
|
||||
if num_previous_rounds >= max_messages:
|
||||
# Trim history but keep system prompt (first message).
|
||||
# Every other message should be an assistant message, so keep 2x
|
||||
# message objects. Also keep the last in progress user message
|
||||
num_keep = 2 * max_messages + 1
|
||||
drop_index = len(message_history.messages) - num_keep
|
||||
message_history.messages = [
|
||||
message_history.messages[0],
|
||||
*message_history.messages[drop_index:],
|
||||
]
|
||||
@@ -284,8 +284,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bo
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -294,11 +292,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
@@ -368,34 +361,4 @@ async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
title=DEFAULT_NAME,
|
||||
options={},
|
||||
version=2,
|
||||
minor_version=2,
|
||||
)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> bool:
|
||||
"""Migrate entry."""
|
||||
LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
|
||||
|
||||
if entry.version > 2:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if entry.version == 2 and entry.minor_version == 1:
|
||||
# Correct broken device migration in Home Assistant Core 2025.7.0b0-2025.7.0b1
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_registry, entry.entry_id
|
||||
):
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=entry.entry_id,
|
||||
remove_config_subentry_id=None,
|
||||
)
|
||||
|
||||
hass.config_entries.async_update_entry(entry, minor_version=2)
|
||||
|
||||
LOGGER.debug(
|
||||
"Migration to version %s:%s successful", entry.version, entry.minor_version
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
@@ -99,7 +99,6 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for OpenAI Conversation."""
|
||||
|
||||
VERSION = 2
|
||||
MINOR_VERSION = 2
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -1,19 +1,73 @@
|
||||
"""Conversation support for OpenAI."""
|
||||
|
||||
from typing import Literal
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import openai
|
||||
from openai._streaming import AsyncStream
|
||||
from openai.types.responses import (
|
||||
EasyInputMessageParam,
|
||||
FunctionToolParam,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallParam,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputMessageParam,
|
||||
ResponseReasoningItem,
|
||||
ResponseReasoningItemParam,
|
||||
ResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ToolParam,
|
||||
WebSearchToolParam,
|
||||
)
|
||||
from openai.types.responses.response_input_param import FunctionCallOutput
|
||||
from openai.types.responses.web_search_tool_param import UserLocation
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import OpenAIBaseLLMEntity
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -32,10 +86,152 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> FunctionToolParam:
|
||||
"""Format tool specification."""
|
||||
return FunctionToolParam(
|
||||
type="function",
|
||||
name=tool.name,
|
||||
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
description=tool.description,
|
||||
strict=False,
|
||||
)
|
||||
|
||||
|
||||
def _convert_content_to_param(
|
||||
content: conversation.Content,
|
||||
) -> ResponseInputParam:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
messages: ResponseInputParam = []
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
return [
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
call_id=content.tool_call_id,
|
||||
output=json.dumps(content.tool_result),
|
||||
)
|
||||
]
|
||||
|
||||
if content.content:
|
||||
role: Literal["user", "assistant", "system", "developer"] = content.role
|
||||
if role == "system":
|
||||
role = "developer"
|
||||
messages.append(
|
||||
EasyInputMessageParam(type="message", role=role, content=content.content)
|
||||
)
|
||||
|
||||
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
|
||||
messages.extend(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
call_id=tool_call.id,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[ResponseStreamEvent],
|
||||
messages: ResponseInputParam,
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform an OpenAI delta stream into HA format."""
|
||||
async for event in result:
|
||||
LOGGER.debug("Received event: %s", event)
|
||||
|
||||
if isinstance(event, ResponseOutputItemAddedEvent):
|
||||
if isinstance(event.item, ResponseOutputMessage):
|
||||
yield {"role": event.item.role}
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
# OpenAI has tool calls as individual events
|
||||
# while HA puts tool calls inside the assistant message.
|
||||
# We turn them into individual assistant content for HA
|
||||
# to ensure that tools are called as soon as possible.
|
||||
yield {"role": "assistant"}
|
||||
current_tool_call = event.item
|
||||
elif isinstance(event, ResponseOutputItemDoneEvent):
|
||||
item = event.item.model_dump()
|
||||
item.pop("status", None)
|
||||
if isinstance(event.item, ResponseReasoningItem):
|
||||
messages.append(cast(ResponseReasoningItemParam, item))
|
||||
elif isinstance(event.item, ResponseOutputMessage):
|
||||
messages.append(cast(ResponseOutputMessageParam, item))
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
messages.append(cast(ResponseFunctionToolCallParam, item))
|
||||
elif isinstance(event, ResponseTextDeltaEvent):
|
||||
yield {"content": event.delta}
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
|
||||
current_tool_call.arguments += event.delta
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
|
||||
current_tool_call.status = "completed"
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_call.call_id,
|
||||
tool_name=current_tool_call.name,
|
||||
tool_args=json.loads(current_tool_call.arguments),
|
||||
)
|
||||
]
|
||||
}
|
||||
elif isinstance(event, ResponseCompletedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
elif isinstance(event, ResponseIncompleteEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
event.response.incomplete_details
|
||||
and event.response.incomplete_details.reason
|
||||
):
|
||||
reason: str = event.response.incomplete_details.reason
|
||||
else:
|
||||
reason = "unknown reason"
|
||||
|
||||
if reason == "max_output_tokens":
|
||||
reason = "max output tokens reached"
|
||||
elif reason == "content_filter":
|
||||
reason = "content filter triggered"
|
||||
|
||||
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
|
||||
elif isinstance(event, ResponseFailedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
reason = "unknown reason"
|
||||
if event.response.error is not None:
|
||||
reason = event.response.error.message
|
||||
raise HomeAssistantError(f"OpenAI response failed: {reason}")
|
||||
elif isinstance(event, ResponseErrorEvent):
|
||||
raise HomeAssistantError(f"OpenAI response error: {event.message}")
|
||||
|
||||
|
||||
class OpenAIConversationEntity(
|
||||
conversation.ConversationEntity,
|
||||
conversation.AbstractConversationAgent,
|
||||
OpenAIBaseLLMEntity,
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
):
|
||||
"""OpenAI conversation agent."""
|
||||
|
||||
@@ -43,7 +239,17 @@ class OpenAIConversationEntity(
|
||||
|
||||
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
super().__init__(entry, subentry)
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="OpenAI",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -61,6 +267,9 @@ class OpenAIConversationEntity(
|
||||
self.hass, "conversation", self.entry.entry_id, self.entity_id
|
||||
)
|
||||
conversation.async_set_agent(self.hass, self.entry, self)
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""When entity will be removed from Home Assistant."""
|
||||
@@ -95,3 +304,95 @@ class OpenAIConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchToolParam(
|
||||
type="web_search_preview",
|
||||
search_context_size=options.get(
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
|
||||
),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = UserLocation(
|
||||
type="approximate",
|
||||
city=options.get(CONF_WEB_SEARCH_CITY, ""),
|
||||
region=options.get(CONF_WEB_SEARCH_REGION, ""),
|
||||
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
|
||||
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
|
||||
)
|
||||
if tools is None:
|
||||
tools = []
|
||||
tools.append(web_search)
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
messages = [
|
||||
m
|
||||
for content in chat_log.content
|
||||
for m in _convert_content_to_param(content)
|
||||
]
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"input": messages,
|
||||
"max_output_tokens": options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
"user": chat_log.conversation_id,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
if model.startswith("o"):
|
||||
model_args["reasoning"] = {
|
||||
"effort": options.get(
|
||||
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
|
||||
)
|
||||
}
|
||||
else:
|
||||
model_args["store"] = False
|
||||
|
||||
try:
|
||||
result = await client.responses.create(**model_args)
|
||||
except openai.RateLimitError as err:
|
||||
LOGGER.error("Rate limited by OpenAI: %s", err)
|
||||
raise HomeAssistantError("Rate limited or insufficient funds") from err
|
||||
except openai.OpenAIError as err:
|
||||
LOGGER.error("Error talking to OpenAI: %s", err)
|
||||
raise HomeAssistantError("Error talking to OpenAI") from err
|
||||
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_stream(chat_log, result, messages)
|
||||
):
|
||||
if not isinstance(content, conversation.AssistantContent):
|
||||
messages.extend(_convert_content_to_param(content))
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
"""Base entity for OpenAI."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import openai
|
||||
from openai._streaming import AsyncStream
|
||||
from openai.types.responses import (
|
||||
EasyInputMessageParam,
|
||||
FunctionToolParam,
|
||||
ResponseCompletedEvent,
|
||||
ResponseErrorEvent,
|
||||
ResponseFailedEvent,
|
||||
ResponseFunctionCallArgumentsDeltaEvent,
|
||||
ResponseFunctionCallArgumentsDoneEvent,
|
||||
ResponseFunctionToolCall,
|
||||
ResponseFunctionToolCallParam,
|
||||
ResponseIncompleteEvent,
|
||||
ResponseInputParam,
|
||||
ResponseOutputItemAddedEvent,
|
||||
ResponseOutputItemDoneEvent,
|
||||
ResponseOutputMessage,
|
||||
ResponseOutputMessageParam,
|
||||
ResponseReasoningItem,
|
||||
ResponseReasoningItemParam,
|
||||
ResponseStreamEvent,
|
||||
ResponseTextDeltaEvent,
|
||||
ToolParam,
|
||||
WebSearchToolParam,
|
||||
)
|
||||
from openai.types.responses.response_input_param import FunctionCallOutput
|
||||
from openai.types.responses.web_search_tool_param import UserLocation
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import OpenAIConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_REASONING_EFFORT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TOP_P,
|
||||
CONF_WEB_SEARCH,
|
||||
CONF_WEB_SEARCH_CITY,
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE,
|
||||
CONF_WEB_SEARCH_COUNTRY,
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_REASONING_EFFORT,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_P,
|
||||
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> FunctionToolParam:
|
||||
"""Format tool specification."""
|
||||
return FunctionToolParam(
|
||||
type="function",
|
||||
name=tool.name,
|
||||
parameters=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
description=tool.description,
|
||||
strict=False,
|
||||
)
|
||||
|
||||
|
||||
def _convert_content_to_param(
|
||||
content: conversation.Content,
|
||||
) -> ResponseInputParam:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
messages: ResponseInputParam = []
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
return [
|
||||
FunctionCallOutput(
|
||||
type="function_call_output",
|
||||
call_id=content.tool_call_id,
|
||||
output=json.dumps(content.tool_result),
|
||||
)
|
||||
]
|
||||
|
||||
if content.content:
|
||||
role: Literal["user", "assistant", "system", "developer"] = content.role
|
||||
if role == "system":
|
||||
role = "developer"
|
||||
messages.append(
|
||||
EasyInputMessageParam(type="message", role=role, content=content.content)
|
||||
)
|
||||
|
||||
if isinstance(content, conversation.AssistantContent) and content.tool_calls:
|
||||
messages.extend(
|
||||
ResponseFunctionToolCallParam(
|
||||
type="function_call",
|
||||
name=tool_call.tool_name,
|
||||
arguments=json.dumps(tool_call.tool_args),
|
||||
call_id=tool_call.id,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
)
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream(
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[ResponseStreamEvent],
|
||||
messages: ResponseInputParam,
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform an OpenAI delta stream into HA format."""
|
||||
async for event in result:
|
||||
LOGGER.debug("Received event: %s", event)
|
||||
|
||||
if isinstance(event, ResponseOutputItemAddedEvent):
|
||||
if isinstance(event.item, ResponseOutputMessage):
|
||||
yield {"role": event.item.role}
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
# OpenAI has tool calls as individual events
|
||||
# while HA puts tool calls inside the assistant message.
|
||||
# We turn them into individual assistant content for HA
|
||||
# to ensure that tools are called as soon as possible.
|
||||
yield {"role": "assistant"}
|
||||
current_tool_call = event.item
|
||||
elif isinstance(event, ResponseOutputItemDoneEvent):
|
||||
item = event.item.model_dump()
|
||||
item.pop("status", None)
|
||||
if isinstance(event.item, ResponseReasoningItem):
|
||||
messages.append(cast(ResponseReasoningItemParam, item))
|
||||
elif isinstance(event.item, ResponseOutputMessage):
|
||||
messages.append(cast(ResponseOutputMessageParam, item))
|
||||
elif isinstance(event.item, ResponseFunctionToolCall):
|
||||
messages.append(cast(ResponseFunctionToolCallParam, item))
|
||||
elif isinstance(event, ResponseTextDeltaEvent):
|
||||
yield {"content": event.delta}
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDeltaEvent):
|
||||
current_tool_call.arguments += event.delta
|
||||
elif isinstance(event, ResponseFunctionCallArgumentsDoneEvent):
|
||||
current_tool_call.status = "completed"
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_tool_call.call_id,
|
||||
tool_name=current_tool_call.name,
|
||||
tool_args=json.loads(current_tool_call.arguments),
|
||||
)
|
||||
]
|
||||
}
|
||||
elif isinstance(event, ResponseCompletedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
elif isinstance(event, ResponseIncompleteEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (
|
||||
event.response.incomplete_details
|
||||
and event.response.incomplete_details.reason
|
||||
):
|
||||
reason: str = event.response.incomplete_details.reason
|
||||
else:
|
||||
reason = "unknown reason"
|
||||
|
||||
if reason == "max_output_tokens":
|
||||
reason = "max output tokens reached"
|
||||
elif reason == "content_filter":
|
||||
reason = "content filter triggered"
|
||||
|
||||
raise HomeAssistantError(f"OpenAI response incomplete: {reason}")
|
||||
elif isinstance(event, ResponseFailedEvent):
|
||||
if event.response.usage is not None:
|
||||
chat_log.async_trace(
|
||||
{
|
||||
"stats": {
|
||||
"input_tokens": event.response.usage.input_tokens,
|
||||
"output_tokens": event.response.usage.output_tokens,
|
||||
}
|
||||
}
|
||||
)
|
||||
reason = "unknown reason"
|
||||
if event.response.error is not None:
|
||||
reason = event.response.error.message
|
||||
raise HomeAssistantError(f"OpenAI response failed: {reason}")
|
||||
elif isinstance(event, ResponseErrorEvent):
|
||||
raise HomeAssistantError(f"OpenAI response error: {event.message}")
|
||||
|
||||
|
||||
class OpenAIBaseLLMEntity(Entity):
|
||||
"""OpenAI conversation agent."""
|
||||
|
||||
def __init__(self, entry: OpenAIConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="OpenAI",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchToolParam(
|
||||
type="web_search_preview",
|
||||
search_context_size=options.get(
|
||||
CONF_WEB_SEARCH_CONTEXT_SIZE, RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE
|
||||
),
|
||||
)
|
||||
if options.get(CONF_WEB_SEARCH_USER_LOCATION):
|
||||
web_search["user_location"] = UserLocation(
|
||||
type="approximate",
|
||||
city=options.get(CONF_WEB_SEARCH_CITY, ""),
|
||||
region=options.get(CONF_WEB_SEARCH_REGION, ""),
|
||||
country=options.get(CONF_WEB_SEARCH_COUNTRY, ""),
|
||||
timezone=options.get(CONF_WEB_SEARCH_TIMEZONE, ""),
|
||||
)
|
||||
if tools is None:
|
||||
tools = []
|
||||
tools.append(web_search)
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
messages = [
|
||||
m
|
||||
for content in chat_log.content
|
||||
for m in _convert_content_to_param(content)
|
||||
]
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"input": messages,
|
||||
"max_output_tokens": options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
"top_p": options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"temperature": options.get(CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE),
|
||||
"user": chat_log.conversation_id,
|
||||
"stream": True,
|
||||
}
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
if model.startswith("o"):
|
||||
model_args["reasoning"] = {
|
||||
"effort": options.get(
|
||||
CONF_REASONING_EFFORT, RECOMMENDED_REASONING_EFFORT
|
||||
)
|
||||
}
|
||||
else:
|
||||
model_args["store"] = False
|
||||
|
||||
try:
|
||||
result = await client.responses.create(**model_args)
|
||||
except openai.RateLimitError as err:
|
||||
LOGGER.error("Rate limited by OpenAI: %s", err)
|
||||
raise HomeAssistantError("Rate limited or insufficient funds") from err
|
||||
except openai.OpenAIError as err:
|
||||
LOGGER.error("Error talking to OpenAI: %s", err)
|
||||
raise HomeAssistantError("Error talking to OpenAI") from err
|
||||
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id, _transform_stream(chat_log, result, messages)
|
||||
):
|
||||
if not isinstance(content, conversation.AssistantContent):
|
||||
messages.extend(_convert_content_to_param(content))
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
@@ -21,7 +21,6 @@ from homeassistant.components.climate import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_TEMPERATURE, CONF_ID, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -31,7 +30,6 @@ from .const import (
|
||||
CONF_SET_PRECISION,
|
||||
DATA_GATEWAYS,
|
||||
DATA_OPENTHERM_GW,
|
||||
DOMAIN,
|
||||
THERMOSTAT_DEVICE_DESCRIPTION,
|
||||
OpenThermDataSource,
|
||||
)
|
||||
@@ -77,7 +75,7 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE
|
||||
)
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_hvac_modes = [HVACMode.HEAT]
|
||||
_attr_hvac_modes = []
|
||||
_attr_name = None
|
||||
_attr_preset_modes = []
|
||||
_attr_min_temp = 1
|
||||
@@ -131,11 +129,9 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
|
||||
if ch_active and flame_on:
|
||||
self._attr_hvac_action = HVACAction.HEATING
|
||||
self._attr_hvac_mode = HVACMode.HEAT
|
||||
self._attr_hvac_modes = [HVACMode.HEAT]
|
||||
elif cooling_active:
|
||||
self._attr_hvac_action = HVACAction.COOLING
|
||||
self._attr_hvac_mode = HVACMode.COOL
|
||||
self._attr_hvac_modes = [HVACMode.COOL]
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.IDLE
|
||||
|
||||
@@ -186,13 +182,6 @@ class OpenThermClimate(OpenThermStatusEntity, ClimateEntity):
|
||||
return PRESET_AWAY
|
||||
return PRESET_NONE
|
||||
|
||||
def set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||
"""Set new target hvac mode."""
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="change_hvac_mode_not_supported",
|
||||
)
|
||||
|
||||
def set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set the preset mode."""
|
||||
_LOGGER.warning("Changing preset mode is not supported")
|
||||
|
||||
@@ -355,9 +355,6 @@
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"change_hvac_mode_not_supported": {
|
||||
"message": "Changing HVAC mode is not supported."
|
||||
},
|
||||
"invalid_gateway_id": {
|
||||
"message": "Gateway {gw_id} not found or not loaded!"
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
"sensor": {
|
||||
"battery": {
|
||||
"state": {
|
||||
"full": "[%key:common::state::full%]",
|
||||
"full": "Full",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"normal": "[%key:common::state::normal%]",
|
||||
"medium": "[%key:common::state::medium%]",
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pypaperless"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pypaperless==4.1.1"]
|
||||
"requirements": ["pypaperless==4.1.0"]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
SERVICE_RELOAD,
|
||||
STATE_HOME,
|
||||
STATE_NOT_HOME,
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
)
|
||||
@@ -525,7 +526,7 @@ class Person(
|
||||
latest_gps = _get_latest(latest_gps, state)
|
||||
elif state.state == STATE_HOME:
|
||||
latest_non_gps_home = _get_latest(latest_non_gps_home, state)
|
||||
else:
|
||||
elif state.state == STATE_NOT_HOME:
|
||||
latest_not_home = _get_latest(latest_not_home, state)
|
||||
|
||||
if latest_non_gps_home:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,10 +27,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: PlugwiseConfigEntry) ->
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, str(coordinator.api.gateway_id))},
|
||||
manufacturer="Plugwise",
|
||||
model=coordinator.api.smile.model,
|
||||
model_id=coordinator.api.smile.model_id,
|
||||
name=coordinator.api.smile.name,
|
||||
sw_version=str(coordinator.api.smile.version),
|
||||
model=coordinator.api.smile_model,
|
||||
model_id=coordinator.api.smile_model_id,
|
||||
name=coordinator.api.smile_name,
|
||||
sw_version=str(coordinator.api.smile_version),
|
||||
) # required for adding the entity-less P1 Gateway
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
@@ -39,7 +39,7 @@ async def async_setup_entry(
|
||||
if not coordinator.new_devices:
|
||||
return
|
||||
|
||||
if coordinator.api.smile.name == "Adam":
|
||||
if coordinator.api.smile_name == "Adam":
|
||||
async_add_entities(
|
||||
PlugwiseClimateEntity(coordinator, device_id)
|
||||
for device_id in coordinator.new_devices
|
||||
@@ -85,7 +85,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
if (
|
||||
self.coordinator.api.cooling_present
|
||||
and coordinator.api.smile.name != "Adam"
|
||||
and coordinator.api.smile_name != "Adam"
|
||||
):
|
||||
self._attr_supported_features = (
|
||||
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
|
||||
|
||||
@@ -204,11 +204,11 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api, errors = await verify_connection(self.hass, user_input)
|
||||
if api:
|
||||
await self.async_set_unique_id(
|
||||
api.smile.hostname or api.gateway_id,
|
||||
api.smile_hostname or api.gateway_id,
|
||||
raise_on_progress=False,
|
||||
)
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=api.smile.name, data=user_input)
|
||||
return self.async_create_entry(title=api.smile_name, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=SOURCE_USER,
|
||||
@@ -236,7 +236,7 @@ class PlugwiseConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
api, errors = await verify_connection(self.hass, full_input)
|
||||
if api:
|
||||
await self.async_set_unique_id(
|
||||
api.smile.hostname or api.gateway_id,
|
||||
api.smile_hostname or api.gateway_id,
|
||||
raise_on_progress=False,
|
||||
)
|
||||
self._abort_if_unique_id_mismatch(reason="not_the_same_smile")
|
||||
|
||||
@@ -48,7 +48,7 @@ class PlugwiseEntity(CoordinatorEntity[PlugwiseDataUpdateCoordinator]):
|
||||
manufacturer=data.get("vendor"),
|
||||
model=data.get("model"),
|
||||
model_id=data.get("model_id"),
|
||||
name=coordinator.api.smile.name,
|
||||
name=coordinator.api.smile_name,
|
||||
sw_version=data.get("firmware"),
|
||||
hw_version=data.get("hardware"),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["plugwise"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["plugwise==1.7.7"],
|
||||
"requirements": ["plugwise==1.7.6"],
|
||||
"zeroconf": ["_plugwise._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"title": "Proximity",
|
||||
"config": {
|
||||
"flow_title": "[%key:component::proximity::title%]",
|
||||
"flow_title": "Proximity",
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"zone": "Zone to track distance to",
|
||||
"ignored_zones": "Zones to ignore",
|
||||
"tracked_entities": "Devices or persons to track",
|
||||
"tracked_entities": "Devices or Persons to track",
|
||||
"tolerance": "Tolerance distance"
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@
|
||||
"step": {
|
||||
"init": {
|
||||
"data": {
|
||||
"zone": "[%key:component::proximity::config::step::user::data::zone%]",
|
||||
"ignored_zones": "[%key:component::proximity::config::step::user::data::ignored_zones%]",
|
||||
"tracked_entities": "[%key:component::proximity::config::step::user::data::tracked_entities%]",
|
||||
"tolerance": "[%key:component::proximity::config::step::user::data::tolerance%]"
|
||||
"zone": "Zone to track distance to",
|
||||
"ignored_zones": "Zones to ignore",
|
||||
"tracked_entities": "Devices or Persons to track",
|
||||
"tolerance": "Tolerance distance"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"dir_of_travel": {
|
||||
"name": "{tracked_entity} direction of travel",
|
||||
"name": "{tracked_entity} Direction of travel",
|
||||
"state": {
|
||||
"arrived": "Arrived",
|
||||
"away_from": "Away from",
|
||||
@@ -40,15 +40,15 @@
|
||||
"towards": "Towards"
|
||||
}
|
||||
},
|
||||
"dist_to_zone": { "name": "{tracked_entity} distance" },
|
||||
"dist_to_zone": { "name": "{tracked_entity} Distance" },
|
||||
"nearest": { "name": "Nearest device" },
|
||||
"nearest_dir_of_travel": {
|
||||
"name": "Nearest direction of travel",
|
||||
"state": {
|
||||
"arrived": "[%key:component::proximity::entity::sensor::dir_of_travel::state::arrived%]",
|
||||
"away_from": "[%key:component::proximity::entity::sensor::dir_of_travel::state::away_from%]",
|
||||
"stationary": "[%key:component::proximity::entity::sensor::dir_of_travel::state::stationary%]",
|
||||
"towards": "[%key:component::proximity::entity::sensor::dir_of_travel::state::towards%]"
|
||||
"arrived": "Arrived",
|
||||
"away_from": "Away from",
|
||||
"stationary": "Stationary",
|
||||
"towards": "Towards"
|
||||
}
|
||||
},
|
||||
"nearest_dist_to_zone": { "name": "Nearest distance" }
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, MAX_LENGTH_STATE_STATE
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
@@ -116,12 +116,7 @@ class PushBulletNotificationSensor(SensorEntity):
|
||||
attributes into self._state_attributes.
|
||||
"""
|
||||
try:
|
||||
value = self.pb_provider.data[self.entity_description.key]
|
||||
# Truncate state value to MAX_LENGTH_STATE_STATE while preserving full content in attributes
|
||||
if isinstance(value, str) and len(value) > MAX_LENGTH_STATE_STATE:
|
||||
self._attr_native_value = value[: MAX_LENGTH_STATE_STATE - 3] + "..."
|
||||
else:
|
||||
self._attr_native_value = value
|
||||
self._attr_native_value = self.pb_provider.data[self.entity_description.key]
|
||||
self._attr_extra_state_attributes = self.pb_provider.data
|
||||
except (KeyError, TypeError):
|
||||
pass
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_LOCALE, DOMAIN, PLATFORMS
|
||||
from .renault_hub import RenaultHub
|
||||
from .services import async_setup_services
|
||||
from .services import setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
type RenaultConfigEntry = ConfigEntry[RenaultHub]
|
||||
@@ -20,7 +20,7 @@ type RenaultConfigEntry = ConfigEntry[RenaultHub]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Renault component."""
|
||||
async_setup_services(hass)
|
||||
setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
@@ -191,8 +191,7 @@ def get_vehicle_proxy(service_call: ServiceCall) -> RenaultVehicleProxy:
|
||||
)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the Renault services."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.14.2"]
|
||||
"requirements": ["reolink-aio==0.14.1"]
|
||||
}
|
||||
|
||||
@@ -6,13 +6,12 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import hdrs
|
||||
from multidict import CIMultiDictProxy
|
||||
import xmltodict
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util.ssl import SSLCipherList
|
||||
|
||||
@@ -78,12 +77,6 @@ class RestData:
|
||||
"""Set url."""
|
||||
self._resource = url
|
||||
|
||||
def _is_expected_content_type(self, content_type: str) -> bool:
|
||||
"""Check if the content type is one we expect (JSON or XML)."""
|
||||
return content_type.startswith(
|
||||
("application/json", "text/json", *XML_MIME_TYPES)
|
||||
)
|
||||
|
||||
def data_without_xml(self) -> str | None:
|
||||
"""If the data is an XML string, convert it to a JSON string."""
|
||||
_LOGGER.debug("Data fetched from resource: %s", self.data)
|
||||
@@ -91,7 +84,7 @@ class RestData:
|
||||
(value := self.data) is not None
|
||||
# If the http request failed, headers will be None
|
||||
and (headers := self.headers) is not None
|
||||
and (content_type := headers.get(hdrs.CONTENT_TYPE))
|
||||
and (content_type := headers.get("content-type"))
|
||||
and content_type.startswith(XML_MIME_TYPES)
|
||||
):
|
||||
value = json_dumps(xmltodict.parse(value))
|
||||
@@ -101,7 +94,7 @@ class RestData:
|
||||
async def async_update(self, log_errors: bool = True) -> None:
|
||||
"""Get the latest data from REST service with provided method."""
|
||||
if not self._session:
|
||||
self._session = async_get_clientsession(
|
||||
self._session = async_create_clientsession(
|
||||
self._hass,
|
||||
verify_ssl=self._verify_ssl,
|
||||
ssl_cipher=self._ssl_cipher_list,
|
||||
@@ -110,12 +103,6 @@ class RestData:
|
||||
rendered_headers = template.render_complex(self._headers, parse_result=False)
|
||||
rendered_params = template.render_complex(self._params)
|
||||
|
||||
# Convert boolean values to lowercase strings for compatibility with aiohttp/yarl
|
||||
if rendered_params:
|
||||
for key, value in rendered_params.items():
|
||||
if isinstance(value, bool):
|
||||
rendered_params[key] = str(value).lower()
|
||||
|
||||
_LOGGER.debug("Updating from %s", self._resource)
|
||||
# Create request kwargs
|
||||
request_kwargs: dict[str, Any] = {
|
||||
@@ -133,7 +120,6 @@ class RestData:
|
||||
# Handle data/content
|
||||
if self._request_data:
|
||||
request_kwargs["data"] = self._request_data
|
||||
response = None
|
||||
try:
|
||||
# Make the request
|
||||
async with self._session.request(
|
||||
@@ -157,34 +143,3 @@ class RestData:
|
||||
self.last_exception = ex
|
||||
self.data = None
|
||||
self.headers = None
|
||||
|
||||
# Log response details outside the try block so we always get logging
|
||||
if response is None:
|
||||
return
|
||||
|
||||
# Log response details for debugging
|
||||
content_type = response.headers.get(hdrs.CONTENT_TYPE)
|
||||
_LOGGER.debug(
|
||||
"REST response from %s: status=%s, content-type=%s, length=%s",
|
||||
self._resource,
|
||||
response.status,
|
||||
content_type or "not set",
|
||||
len(self.data) if self.data else 0,
|
||||
)
|
||||
|
||||
# If we got an error response with non-JSON/XML content, log a sample
|
||||
# This helps debug issues like servers blocking with HTML error pages
|
||||
if (
|
||||
response.status >= 400
|
||||
and content_type
|
||||
and not self._is_expected_content_type(content_type)
|
||||
):
|
||||
sample = self.data[:500] if self.data else "<empty>"
|
||||
_LOGGER.warning(
|
||||
"REST request to %s returned status %s with %s response: %s%s",
|
||||
self._resource,
|
||||
response.status,
|
||||
content_type,
|
||||
sample,
|
||||
"..." if self.data and len(self.data) > 500 else "",
|
||||
)
|
||||
|
||||
@@ -86,10 +86,7 @@ def async_setup_block_attribute_entities(
|
||||
coordinator.device.settings, block
|
||||
):
|
||||
domain = sensor_class.__module__.split(".")[-1]
|
||||
unique_id = sensor_class(
|
||||
coordinator, block, sensor_id, description
|
||||
).unique_id
|
||||
LOGGER.debug("Removing Shelly entity with unique_id: %s", unique_id)
|
||||
unique_id = f"{coordinator.mac}-{block.description}-{sensor_id}"
|
||||
async_remove_shelly_entity(hass, domain, unique_id)
|
||||
else:
|
||||
entities.append(
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioshelly"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioshelly==13.7.1"],
|
||||
"requirements": ["aioshelly==13.7.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
@@ -39,9 +39,8 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNOWN_PLAYERS,
|
||||
KNOWN_SERVERS,
|
||||
SERVER_MANUFACTURER,
|
||||
MANUFACTURER,
|
||||
SERVER_MODEL,
|
||||
SERVER_MODEL_ID,
|
||||
SIGNAL_PLAYER_DISCOVERED,
|
||||
SIGNAL_PLAYER_REDISCOVERED,
|
||||
STATUS_API_TIMEOUT,
|
||||
@@ -174,9 +173,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: SqueezeboxConfigEntry) -
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, lms.uuid)},
|
||||
name=lms.name,
|
||||
manufacturer=SERVER_MANUFACTURER,
|
||||
manufacturer=MANUFACTURER,
|
||||
model=SERVER_MODEL,
|
||||
model_id=SERVER_MODEL_ID,
|
||||
sw_version=version,
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
connections=mac_connect,
|
||||
|
||||
@@ -6,11 +6,10 @@ DOMAIN = "squeezebox"
|
||||
DEFAULT_PORT = 9000
|
||||
KNOWN_PLAYERS = "known_players"
|
||||
KNOWN_SERVERS = "known_servers"
|
||||
MANUFACTURER = "https://lyrion.org/"
|
||||
PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub"
|
||||
SENSOR_UPDATE_INTERVAL = 60
|
||||
SERVER_MANUFACTURER = "https://lyrion.org/"
|
||||
SERVER_MODEL = "Lyrion Music Server"
|
||||
SERVER_MODEL_ID = "LMS"
|
||||
STATUS_API_TIMEOUT = 10
|
||||
STATUS_SENSOR_LASTSCAN = "lastscan"
|
||||
STATUS_SENSOR_NEEDSRESTART = "needsrestart"
|
||||
|
||||
@@ -26,7 +26,11 @@ class SqueezeboxEntity(CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator]):
|
||||
self._player = coordinator.player
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, format_mac(self._player.player_id))},
|
||||
name=self._player.name,
|
||||
connections={(CONNECTION_NETWORK_MAC, format_mac(self._player.player_id))},
|
||||
via_device=(DOMAIN, coordinator.server_uuid),
|
||||
model=self._player.model,
|
||||
manufacturer=self._player.creator,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -33,12 +33,11 @@ from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
discovery_flow,
|
||||
entity_platform,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, format_mac
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.start import async_at_start
|
||||
@@ -62,9 +61,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
KNOWN_PLAYERS,
|
||||
KNOWN_SERVERS,
|
||||
SERVER_MANUFACTURER,
|
||||
SERVER_MODEL,
|
||||
SERVER_MODEL_ID,
|
||||
SIGNAL_PLAYER_DISCOVERED,
|
||||
SQUEEZEBOX_SOURCE_STRINGS,
|
||||
)
|
||||
@@ -129,52 +125,9 @@ async def async_setup_entry(
|
||||
"""Set up the Squeezebox media_player platform from a server config entry."""
|
||||
|
||||
# Add media player entities when discovered
|
||||
async def _player_discovered(
|
||||
coordinator: SqueezeBoxPlayerUpdateCoordinator,
|
||||
) -> None:
|
||||
player = coordinator.player
|
||||
_LOGGER.debug("Setting up media_player device and entity for player %s", player)
|
||||
device_registry = dr.async_get(hass)
|
||||
server_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, coordinator.server_uuid)},
|
||||
)
|
||||
|
||||
name = player.name
|
||||
model = player.model
|
||||
manufacturer = player.creator
|
||||
model_id = player.model_type
|
||||
sw_version = ""
|
||||
# Why? so we nicely merge with a server and a player linked by a MAC server is not all info lost
|
||||
if (
|
||||
server_device
|
||||
and (CONNECTION_NETWORK_MAC, format_mac(player.player_id))
|
||||
in server_device.connections
|
||||
):
|
||||
_LOGGER.debug("Shared server & player device %s", server_device)
|
||||
name = server_device.name
|
||||
sw_version = server_device.sw_version or sw_version
|
||||
model = SERVER_MODEL + "/" + model if model else SERVER_MODEL
|
||||
manufacturer = (
|
||||
SERVER_MANUFACTURER + " / " + manufacturer
|
||||
if manufacturer
|
||||
else SERVER_MANUFACTURER
|
||||
)
|
||||
model_id = SERVER_MODEL_ID + "/" + model_id if model_id else SERVER_MODEL_ID
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, player.player_id)},
|
||||
connections={(CONNECTION_NETWORK_MAC, player.player_id)},
|
||||
name=name,
|
||||
model=model,
|
||||
manufacturer=manufacturer,
|
||||
model_id=model_id,
|
||||
hw_version=player.firmware,
|
||||
sw_version=sw_version,
|
||||
via_device=(DOMAIN, coordinator.server_uuid),
|
||||
)
|
||||
_LOGGER.debug("Creating / Updating player device %s", device)
|
||||
async_add_entities([SqueezeBoxMediaPlayerEntity(coordinator)])
|
||||
async def _player_discovered(player: SqueezeBoxPlayerUpdateCoordinator) -> None:
|
||||
_LOGGER.debug("Setting up media_player entity for player %s", player)
|
||||
async_add_entities([SqueezeBoxMediaPlayerEntity(player)])
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(hass, SIGNAL_PLAYER_DISCOVERED, _player_discovered)
|
||||
|
||||
@@ -55,7 +55,6 @@ from .const import (
|
||||
MAX_SEGMENTS,
|
||||
OUTPUT_FORMATS,
|
||||
OUTPUT_IDLE_TIMEOUT,
|
||||
OUTPUT_STARTUP_TIMEOUT,
|
||||
RECORDER_PROVIDER,
|
||||
RTSP_TRANSPORTS,
|
||||
SEGMENT_DURATION_ADJUSTER,
|
||||
@@ -364,14 +363,11 @@ class Stream:
|
||||
# without concern about self._outputs being modified from another thread.
|
||||
return MappingProxyType(self._outputs.copy())
|
||||
|
||||
def add_provider(self, fmt: str, timeout: int | None = None) -> StreamOutput:
|
||||
def add_provider(
|
||||
self, fmt: str, timeout: int = OUTPUT_IDLE_TIMEOUT
|
||||
) -> StreamOutput:
|
||||
"""Add provider output stream."""
|
||||
if not (provider := self._outputs.get(fmt)):
|
||||
startup_timeout = OUTPUT_STARTUP_TIMEOUT
|
||||
if timeout is None:
|
||||
timeout = OUTPUT_IDLE_TIMEOUT
|
||||
else:
|
||||
startup_timeout = timeout
|
||||
|
||||
async def idle_callback() -> None:
|
||||
if (
|
||||
@@ -383,7 +379,7 @@ class Stream:
|
||||
|
||||
provider = PROVIDERS[fmt](
|
||||
self.hass,
|
||||
IdleTimer(self.hass, timeout, idle_callback, startup_timeout),
|
||||
IdleTimer(self.hass, timeout, idle_callback),
|
||||
self._stream_settings,
|
||||
self.dynamic_stream_settings,
|
||||
)
|
||||
|
||||
@@ -22,8 +22,7 @@ AUDIO_CODECS = {"aac", "mp3"}
|
||||
|
||||
FORMAT_CONTENT_TYPE = {HLS_PROVIDER: "application/vnd.apple.mpegurl"}
|
||||
|
||||
OUTPUT_STARTUP_TIMEOUT = 60 # timeout due to no startup
|
||||
OUTPUT_IDLE_TIMEOUT = 30 # Idle timeout due to inactivity
|
||||
OUTPUT_IDLE_TIMEOUT = 300 # Idle timeout due to inactivity
|
||||
|
||||
NUM_PLAYLIST_SEGMENTS = 3 # Number of segments to use in HLS playlist
|
||||
MAX_SEGMENTS = 5 # Max number of segments to keep around
|
||||
|
||||
@@ -234,12 +234,10 @@ class IdleTimer:
|
||||
hass: HomeAssistant,
|
||||
timeout: int,
|
||||
idle_callback: Callable[[], Coroutine[Any, Any, None]],
|
||||
startup_timeout: int | None = None,
|
||||
) -> None:
|
||||
"""Initialize IdleTimer."""
|
||||
self._hass = hass
|
||||
self._timeout = timeout
|
||||
self._startup_timeout = startup_timeout or timeout
|
||||
self._callback = idle_callback
|
||||
self._unsub: CALLBACK_TYPE | None = None
|
||||
self.idle = False
|
||||
@@ -248,7 +246,7 @@ class IdleTimer:
|
||||
"""Start the idle timer if not already started."""
|
||||
self.idle = False
|
||||
if self._unsub is None:
|
||||
self._unsub = async_call_later(self._hass, self._startup_timeout, self.fire)
|
||||
self._unsub = async_call_later(self._hass, self._timeout, self.fire)
|
||||
|
||||
def awake(self) -> None:
|
||||
"""Keep the idle time alive by resetting the timeout."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -303,9 +303,11 @@ class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity):
|
||||
self._delay_cancel()
|
||||
self._delay_cancel = None
|
||||
|
||||
state: bool | None = None
|
||||
if result is not None and not isinstance(result, TemplateError):
|
||||
state = template.result_as_boolean(result)
|
||||
state = (
|
||||
None
|
||||
if isinstance(result, TemplateError)
|
||||
else template.result_as_boolean(result)
|
||||
)
|
||||
|
||||
if state == self._attr_is_on:
|
||||
return
|
||||
@@ -345,7 +347,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity
|
||||
"""Initialize the entity."""
|
||||
super().__init__(hass, coordinator, config)
|
||||
|
||||
for key in (CONF_STATE, CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF):
|
||||
for key in (CONF_DELAY_ON, CONF_DELAY_OFF, CONF_AUTO_OFF):
|
||||
if isinstance(config.get(key), template.Template):
|
||||
self._to_render_simple.append(key)
|
||||
self._parse_result.add(key)
|
||||
@@ -389,9 +391,7 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity
|
||||
self._process_data()
|
||||
|
||||
raw = self._rendered.get(CONF_STATE)
|
||||
state: bool | None = None
|
||||
if raw is not None:
|
||||
state = template.result_as_boolean(raw)
|
||||
state = template.result_as_boolean(raw)
|
||||
|
||||
key = CONF_DELAY_ON if state else CONF_DELAY_OFF
|
||||
delay = self._rendered.get(key) or self._config.get(key)
|
||||
@@ -417,8 +417,8 @@ class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity
|
||||
self.async_write_ha_state()
|
||||
return
|
||||
|
||||
# state without delay.
|
||||
if self._attr_is_on == state or delay is None:
|
||||
# state without delay. None means rendering failed.
|
||||
if self._attr_is_on == state or state is None or delay is None:
|
||||
self._set_state(state)
|
||||
return
|
||||
|
||||
|
||||
@@ -467,7 +467,7 @@
|
||||
"name": "Tire pressure rear right"
|
||||
},
|
||||
"version": {
|
||||
"name": "Version"
|
||||
"name": "version"
|
||||
},
|
||||
"vin": {
|
||||
"name": "Vehicle"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user