mirror of
https://github.com/home-assistant/core.git
synced 2025-11-10 19:40:11 +00:00
Compare commits
63 Commits
copilot/ad
...
light_targ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72c2d9f44 | ||
|
|
7b1eb5f6fb | ||
|
|
30713d8eb6 | ||
|
|
8b28fe3593 | ||
|
|
07ef61dd8d | ||
|
|
037e0e93d3 | ||
|
|
db8b5865b3 | ||
|
|
bd2ccc6672 | ||
|
|
bb63d40cdf | ||
|
|
65285b8885 | ||
|
|
326b8f2b4f | ||
|
|
9f3df52fcc | ||
|
|
875838c277 | ||
|
|
adaafd1fda | ||
|
|
50c5efddaa | ||
|
|
c4be054161 | ||
|
|
61186356f3 | ||
|
|
9d60a19440 | ||
|
|
108c212855 | ||
|
|
ae8db81c4e | ||
|
|
51c970d1d0 | ||
|
|
1bf6771a54 | ||
|
|
e7a7cb829e | ||
|
|
89c774fc76 | ||
|
|
53b4893ee1 | ||
|
|
3dee03c46c | ||
|
|
86a412be21 | ||
|
|
f23dae4751 | ||
|
|
6f6b2f1ad3 | ||
|
|
1cc4890f75 | ||
|
|
7541c291d5 | ||
|
|
c08bbbdf4c | ||
|
|
bc34ce0dba | ||
|
|
6ff4d7a6d1 | ||
|
|
86d84b1342 | ||
|
|
2303d8089d | ||
|
|
381368644f | ||
|
|
b9ce6de97c | ||
|
|
ca12d7b043 | ||
|
|
d3dd9b26c9 | ||
|
|
a64d61df05 | ||
|
|
e7c6c5311d | ||
|
|
b5e9910856 | ||
|
|
d3d5d7acac | ||
|
|
9e42dc3a12 | ||
|
|
8d32577c72 | ||
|
|
72a524c868 | ||
|
|
3f0735be8e | ||
|
|
b437113f31 | ||
|
|
e0e263d3b5 | ||
|
|
0dfc375c23 | ||
|
|
42f4513ce2 | ||
|
|
1fb657c439 | ||
|
|
53c6c0bac7 | ||
|
|
c27c4488ef | ||
|
|
a8fa656395 | ||
|
|
b2bd1a4aa0 | ||
|
|
cef2ccae65 | ||
|
|
89f5814310 | ||
|
|
f378c96c9d | ||
|
|
f4f71777d2 | ||
|
|
37807fbf5a | ||
|
|
ffc23a5dda |
@@ -25,7 +25,7 @@ from .const import (
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
80
homeassistant/components/anthropic/ai_task.py
Normal file
80
homeassistant/components/anthropic/ai_task.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""AI Task integration for Anthropic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
from homeassistant.components import ai_task, conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AI Task entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "ai_task_data":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[AnthropicTaskEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class AnthropicTaskEntity(
|
||||
ai_task.AITaskEntity,
|
||||
AnthropicBaseLLMEntity,
|
||||
):
|
||||
"""Anthropic AI Task entity."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
|
||||
)
|
||||
|
||||
async def _async_generate_data(
|
||||
self,
|
||||
task: ai_task.GenDataTask,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> ai_task.GenDataTaskResult:
|
||||
"""Handle a generate data task."""
|
||||
await self._async_handle_chat_log(chat_log, task.name, task.structure)
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
raise HomeAssistantError(
|
||||
"Last content in chat log is not an AssistantContent"
|
||||
)
|
||||
|
||||
text = chat_log.content[-1].content or ""
|
||||
|
||||
if not task.structure:
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
data=text,
|
||||
)
|
||||
try:
|
||||
data = json_loads(text)
|
||||
except JSONDecodeError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to parse JSON response: %s. Response: %s",
|
||||
err,
|
||||
text,
|
||||
)
|
||||
raise HomeAssistantError("Error with Claude structured response") from err
|
||||
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
data=data,
|
||||
)
|
||||
@@ -53,6 +53,7 @@ from .const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
NON_THINKING_MODELS,
|
||||
@@ -74,12 +75,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
RECOMMENDED_OPTIONS = {
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -102,7 +107,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
@@ -130,10 +135,16 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_OPTIONS,
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -147,7 +158,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"conversation": ConversationSubentryFlowHandler}
|
||||
return {
|
||||
"conversation": ConversationSubentryFlowHandler,
|
||||
"ai_task_data": ConversationSubentryFlowHandler,
|
||||
}
|
||||
|
||||
|
||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
@@ -164,7 +178,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.options = RECOMMENDED_OPTIONS.copy()
|
||||
if self._subentry_type == "ai_task_data":
|
||||
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
@@ -198,23 +215,29 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if self._is_new:
|
||||
step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = (
|
||||
str
|
||||
if self._subentry_type == "ai_task_data":
|
||||
default_name = DEFAULT_AI_TASK_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
step_schema[vol.Required(CONF_NAME, default=default_name)] = str
|
||||
|
||||
if self._subentry_type == "conversation":
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
step_schema[
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
|
||||
)
|
||||
] = bool
|
||||
|
||||
if user_input is not None:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
@@ -298,10 +321,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
if not model.startswith(tuple(NON_THINKING_MODELS)):
|
||||
step_schema[
|
||||
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
|
||||
] = NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
|
||||
)
|
||||
] = vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
)
|
||||
else:
|
||||
self.options.pop(CONF_THINKING_BUDGET, None)
|
||||
|
||||
@@ -6,6 +6,7 @@ DOMAIN = "anthropic"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||
DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic.types import (
|
||||
Base64ImageSourceParam,
|
||||
Base64PDFSourceParam,
|
||||
CitationsDelta,
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
ImageBlockParam,
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
@@ -37,6 +44,9 @@ from anthropic.types import (
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolChoiceAnyParam,
|
||||
ToolChoiceAutoParam,
|
||||
ToolChoiceToolParam,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUnionParam,
|
||||
@@ -50,13 +60,16 @@ from anthropic.types import (
|
||||
WebSearchToolResultError,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
@@ -321,6 +334,7 @@ def _convert_content(
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
stream: AsyncStream[MessageStreamEvent],
|
||||
output_tool: str | None = None,
|
||||
) -> AsyncGenerator[
|
||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||
]:
|
||||
@@ -381,6 +395,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
if first_block or content_details.has_content():
|
||||
if content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {"role": "assistant"}
|
||||
has_native = False
|
||||
first_block = False
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
first_block
|
||||
@@ -471,7 +495,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
if (
|
||||
current_tool_block is not None
|
||||
and current_tool_block["name"] == output_tool
|
||||
):
|
||||
content_details.citation_details[-1].length += len(
|
||||
response.delta.partial_json
|
||||
)
|
||||
yield {"content": response.delta.partial_json}
|
||||
else:
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
content_details.citation_details[-1].length += len(response.delta.text)
|
||||
yield {"content": response.delta.text}
|
||||
@@ -490,6 +523,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
content_details.add_citation(response.delta.citation)
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_tool_block is not None:
|
||||
if current_tool_block["name"] == output_tool:
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] = tool_args
|
||||
yield {
|
||||
@@ -557,6 +593,8 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
structure_name: str | None = None,
|
||||
structure: vol.Schema | None = None,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
@@ -613,6 +651,74 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
}
|
||||
tools.append(web_search)
|
||||
|
||||
# Handle attachments by adding them to the last user message
|
||||
last_content = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
last_message = messages[-1]
|
||||
if last_message["role"] != "user":
|
||||
raise HomeAssistantError(
|
||||
"Last message must be a user message to add attachments"
|
||||
)
|
||||
if isinstance(last_message["content"], str):
|
||||
last_message["content"] = [
|
||||
TextBlockParam(type="text", text=last_message["content"])
|
||||
]
|
||||
last_message["content"].extend( # type: ignore[union-attr]
|
||||
await async_prepare_files_for_prompt(
|
||||
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
|
||||
)
|
||||
)
|
||||
|
||||
if structure and structure_name:
|
||||
structure_name = slugify(structure_name)
|
||||
if model_args["thinking"]["type"] == "disabled":
|
||||
if not tools:
|
||||
# Simplest case: no tools and no extended thinking
|
||||
# Add a tool and force its use
|
||||
model_args["tool_choice"] = ToolChoiceToolParam(
|
||||
type="tool",
|
||||
name=structure_name,
|
||||
)
|
||||
else:
|
||||
# Second case: tools present but no extended thinking
|
||||
# Allow the model to use any tool but not text response
|
||||
# The model should know to use the right tool by its description
|
||||
model_args["tool_choice"] = ToolChoiceAnyParam(
|
||||
type="any",
|
||||
)
|
||||
else:
|
||||
# Extended thinking is enabled. With extended thinking, we cannot
|
||||
# force tool use or disable text responses, so we add a hint to the
|
||||
# system prompt instead. With extended thinking, the model should be
|
||||
# smart enough to use the tool.
|
||||
model_args["tool_choice"] = ToolChoiceAutoParam(
|
||||
type="auto",
|
||||
)
|
||||
|
||||
if isinstance(model_args["system"], str):
|
||||
model_args["system"] = [
|
||||
TextBlockParam(type="text", text=model_args["system"])
|
||||
]
|
||||
model_args["system"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
|
||||
)
|
||||
)
|
||||
|
||||
tools.append(
|
||||
ToolParam(
|
||||
name=structure_name,
|
||||
description="Use this tool to reply to the user",
|
||||
input_schema=convert(
|
||||
structure,
|
||||
custom_serializer=chat_log.llm_api.custom_serializer
|
||||
if chat_log.llm_api
|
||||
else llm.selector_serializer,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
@@ -629,7 +735,11 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream),
|
||||
_transform_stream(
|
||||
chat_log,
|
||||
stream,
|
||||
output_tool=structure_name if structure else None,
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -641,3 +751,59 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
|
||||
async def async_prepare_files_for_prompt(
|
||||
hass: HomeAssistant, files: list[tuple[Path, str | None]]
|
||||
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
|
||||
"""Append files to a prompt.
|
||||
|
||||
Caller needs to ensure that the files are allowed.
|
||||
"""
|
||||
|
||||
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
|
||||
content: list[ImageBlockParam | DocumentBlockParam] = []
|
||||
|
||||
for file_path, mime_type in files:
|
||||
if not file_path.exists():
|
||||
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||
|
||||
if mime_type is None:
|
||||
mime_type = guess_file_type(file_path)[0]
|
||||
|
||||
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||
raise HomeAssistantError(
|
||||
"Only images and PDF are supported by the Anthropic API,"
|
||||
f"`{file_path}` is not an image file or PDF"
|
||||
)
|
||||
if mime_type == "image/jpg":
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
|
||||
|
||||
if mime_type.startswith("image/"):
|
||||
content.append(
|
||||
ImageBlockParam(
|
||||
type="image",
|
||||
source=Base64ImageSourceParam(
|
||||
type="base64",
|
||||
media_type=mime_type, # type: ignore[typeddict-item]
|
||||
data=base64_file,
|
||||
),
|
||||
)
|
||||
)
|
||||
elif mime_type.startswith("application/pdf"):
|
||||
content.append(
|
||||
DocumentBlockParam(
|
||||
type="document",
|
||||
source=Base64PDFSourceParam(
|
||||
type="base64",
|
||||
media_type=mime_type, # type: ignore[typeddict-item]
|
||||
data=base64_file,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
return await hass.async_add_executor_job(append_files_to_content)
|
||||
|
||||
@@ -18,6 +18,49 @@
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"ai_task_data": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::anthropic::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure AI task",
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
},
|
||||
"init": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "[%key:component::anthropic::config_subentries::conversation::step::init::data::recommended%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::init::title%]"
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
|
||||
},
|
||||
"data_description": {
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::model::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
|
||||
@@ -46,7 +89,8 @@
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
}
|
||||
},
|
||||
"title": "Basic settings"
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
|
||||
@@ -770,7 +770,9 @@ class ManifestJSONView(HomeAssistantView):
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
"type": "frontend/get_icons",
|
||||
vol.Required("category"): vol.In({"entity", "entity_component", "services"}),
|
||||
vol.Required("category"): vol.In(
|
||||
{"entity", "entity_component", "services", "triggers"}
|
||||
),
|
||||
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
|
||||
}
|
||||
)
|
||||
|
||||
131
homeassistant/components/light/condition.py
Normal file
131
homeassistant/components/light/condition.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Provides conditions for lights."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, Final, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_OPTIONS, CONF_TARGET, STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv, target
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
ConditionCheckerType,
|
||||
ConditionConfig,
|
||||
trace_condition_function,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
ATTR_BEHAVIOR: Final = "behavior"
|
||||
BEHAVIOR_ANY: Final = "any"
|
||||
BEHAVIOR_ALL: Final = "all"
|
||||
|
||||
|
||||
STATE_CONDITION_VALID_STATES: Final = [STATE_ON, STATE_OFF]
|
||||
STATE_CONDITION_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_ANY, BEHAVIOR_ALL]
|
||||
),
|
||||
}
|
||||
STATE_CONDITION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
vol.Required(CONF_OPTIONS): STATE_CONDITION_OPTIONS_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StateConditionBase(Condition):
|
||||
"""State condition."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return STATE_CONDITION_SCHEMA(config) # type: ignore[no-any-return]
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, config: ConditionConfig, state: str
|
||||
) -> None:
|
||||
"""Initialize condition."""
|
||||
self._hass = hass
|
||||
if TYPE_CHECKING:
|
||||
assert config.target
|
||||
assert config.options
|
||||
self._target = config.target
|
||||
self._behavior = config.options[ATTR_BEHAVIOR]
|
||||
self._state = state
|
||||
|
||||
@override
|
||||
async def async_get_checker(self) -> ConditionCheckerType:
|
||||
"""Get the condition checker."""
|
||||
|
||||
def check_any_match_state(states: list[str]) -> bool:
|
||||
"""Test if any entity match the state."""
|
||||
return any(state == self._state for state in states)
|
||||
|
||||
def check_all_match_state(states: list[str]) -> bool:
|
||||
"""Test if all entities match the state."""
|
||||
return all(state == self._state for state in states)
|
||||
|
||||
matcher: Callable[[list[str]], bool]
|
||||
if self._behavior == BEHAVIOR_ANY:
|
||||
matcher = check_any_match_state
|
||||
elif self._behavior == BEHAVIOR_ALL:
|
||||
matcher = check_all_match_state
|
||||
|
||||
@trace_condition_function
|
||||
def test_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
|
||||
"""Test state condition."""
|
||||
selector_data = target.TargetSelectorData(self._target)
|
||||
targeted_entities = target.async_extract_referenced_entity_ids(
|
||||
hass, selector_data, expand_group=False
|
||||
)
|
||||
referenced_entity_ids = targeted_entities.referenced.union(
|
||||
targeted_entities.indirectly_referenced
|
||||
)
|
||||
light_entity_ids = {
|
||||
entity_id
|
||||
for entity_id in referenced_entity_ids
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
light_entity_states = [
|
||||
state.state
|
||||
for entity_id in light_entity_ids
|
||||
if (state := hass.states.get(entity_id))
|
||||
and state.state in STATE_CONDITION_VALID_STATES
|
||||
]
|
||||
return matcher(light_entity_states)
|
||||
|
||||
return test_state
|
||||
|
||||
|
||||
class StateOnCondition(StateConditionBase):
|
||||
"""State ON condition."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
||||
"""Initialize condition."""
|
||||
super().__init__(hass, config, STATE_ON)
|
||||
|
||||
|
||||
class StateOffCondition(StateConditionBase):
|
||||
"""State OFF condition."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
|
||||
"""Initialize condition."""
|
||||
super().__init__(hass, config, STATE_OFF)
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"state_off": StateOffCondition,
|
||||
"state_on": StateOnCondition,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the light conditions."""
|
||||
return CONDITIONS
|
||||
28
homeassistant/components/light/conditions.yaml
Normal file
28
homeassistant/components/light/conditions.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
state_off:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
state_on:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
@@ -1,4 +1,12 @@
|
||||
{
|
||||
"conditions": {
|
||||
"state_off": {
|
||||
"condition": "mdi:lightbulb-off"
|
||||
},
|
||||
"state_on": {
|
||||
"condition": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:lightbulb",
|
||||
@@ -25,5 +33,10 @@
|
||||
"turn_on": {
|
||||
"service": "mdi:lightbulb-on"
|
||||
}
|
||||
},
|
||||
"triggers": {
|
||||
"state": {
|
||||
"trigger": "mdi:state-machine"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,30 @@
|
||||
"field_xy_color_name": "XY-color",
|
||||
"section_advanced_fields_name": "Advanced options"
|
||||
},
|
||||
"conditions": {
|
||||
"state_off": {
|
||||
"description": "Test if a light is off.",
|
||||
"description_configured": "Test if a light is off",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "How the state should match on the targeted lights.",
|
||||
"name": "Behavior"
|
||||
}
|
||||
},
|
||||
"name": "If a light is off"
|
||||
},
|
||||
"state_on": {
|
||||
"description": "Test if a light is on.",
|
||||
"description_configured": "Test if a light is on",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "How the state should match on the targeted lights.",
|
||||
"name": "Behavior"
|
||||
}
|
||||
},
|
||||
"name": "If a light is on"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"brightness_decrease": "Decrease {entity_name} brightness",
|
||||
@@ -284,11 +308,30 @@
|
||||
"yellowgreen": "Yellow green"
|
||||
}
|
||||
},
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"flash": {
|
||||
"options": {
|
||||
"long": "Long",
|
||||
"short": "Short"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"options": {
|
||||
"off": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::on%]"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
"first": "First",
|
||||
"last": "Last"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -462,5 +505,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"title": "Light"
|
||||
"title": "Light",
|
||||
"triggers": {
|
||||
"state": {
|
||||
"description": "When the state of a light changes, such as turning on or off.",
|
||||
"description_configured": "When the state of a light changes",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "The behavior of the targeted entities to trigger on.",
|
||||
"name": "Behavior"
|
||||
},
|
||||
"state": {
|
||||
"description": "The state to trigger on.",
|
||||
"name": "State"
|
||||
}
|
||||
},
|
||||
"name": "State"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
152
homeassistant/components/light/trigger.py
Normal file
152
homeassistant/components/light/trigger.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Provides triggers for lights."""
|
||||
|
||||
from typing import TYPE_CHECKING, Final, cast, override
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
CONF_STATE,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.event import process_state_match
|
||||
from homeassistant.helpers.target import (
|
||||
TargetStateChangedData,
|
||||
async_track_target_selector_state_change_event,
|
||||
)
|
||||
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
# remove when #151314 is merged
|
||||
CONF_OPTIONS: Final = "options"
|
||||
|
||||
ATTR_BEHAVIOR: Final = "behavior"
|
||||
BEHAVIOR_FIRST: Final = "first"
|
||||
BEHAVIOR_LAST: Final = "last"
|
||||
BEHAVIOR_ANY: Final = "any"
|
||||
|
||||
STATE_PLATFORM_TYPE: Final = "state"
|
||||
STATE_TRIGGER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_OPTIONS): {
|
||||
vol.Required(CONF_STATE): vol.In([STATE_ON, STATE_OFF]),
|
||||
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
|
||||
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
|
||||
),
|
||||
},
|
||||
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class StateTrigger(Trigger):
|
||||
"""Trigger for state changes."""
|
||||
|
||||
@override
|
||||
@classmethod
|
||||
async def async_validate_config(
|
||||
cls, hass: HomeAssistant, config: ConfigType
|
||||
) -> ConfigType:
|
||||
"""Validate config."""
|
||||
return cast(ConfigType, STATE_TRIGGER_SCHEMA(config))
|
||||
|
||||
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
|
||||
"""Initialize the state trigger."""
|
||||
super().__init__(hass, config)
|
||||
if TYPE_CHECKING:
|
||||
assert config.options is not None
|
||||
assert config.target is not None
|
||||
self._options = config.options
|
||||
self._target = config.target
|
||||
|
||||
@override
|
||||
async def async_attach_runner(
|
||||
self, run_action: TriggerActionRunner
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Attach the trigger to an action runner."""
|
||||
match_config_state = process_state_match(self._options.get(CONF_STATE))
|
||||
|
||||
def check_all_match(entity_ids: set[str]) -> bool:
|
||||
"""Check if all entity states match."""
|
||||
return all(
|
||||
match_config_state(state.state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
)
|
||||
|
||||
def check_one_match(entity_ids: set[str]) -> bool:
|
||||
"""Check that only one entity state matches."""
|
||||
return (
|
||||
sum(
|
||||
match_config_state(state.state)
|
||||
for entity_id in entity_ids
|
||||
if (state := self._hass.states.get(entity_id)) is not None
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
behavior = self._options.get(ATTR_BEHAVIOR)
|
||||
|
||||
@callback
|
||||
def state_change_listener(
|
||||
target_state_change_data: TargetStateChangedData,
|
||||
) -> None:
|
||||
"""Listen for state changes and call action."""
|
||||
event = target_state_change_data.state_change_event
|
||||
entity_id = event.data["entity_id"]
|
||||
from_state = event.data["old_state"]
|
||||
to_state = event.data["new_state"]
|
||||
|
||||
if to_state is None:
|
||||
return
|
||||
|
||||
# This check is required for "first" behavior, to check that it went from zero
|
||||
# entities matching the state to one. Otherwise, if previously there were two
|
||||
# entities on CONF_STATE and one changed, this would trigger.
|
||||
# For "last" behavior it is not required, but serves as a quicker fail check.
|
||||
if not match_config_state(to_state.state):
|
||||
return
|
||||
if behavior == BEHAVIOR_LAST:
|
||||
if not check_all_match(target_state_change_data.targeted_entity_ids):
|
||||
return
|
||||
elif behavior == BEHAVIOR_FIRST:
|
||||
if not check_one_match(target_state_change_data.targeted_entity_ids):
|
||||
return
|
||||
|
||||
run_action(
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
"from_state": from_state,
|
||||
"to_state": to_state,
|
||||
},
|
||||
f"state of {entity_id}",
|
||||
event.context,
|
||||
)
|
||||
|
||||
def entity_filter(entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain."""
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if split_entity_id(entity_id)[0] == DOMAIN
|
||||
}
|
||||
|
||||
return async_track_target_selector_state_change_event(
|
||||
self._hass, self._target, state_change_listener, entity_filter
|
||||
)
|
||||
|
||||
|
||||
TRIGGERS: dict[str, type[Trigger]] = {
|
||||
STATE_PLATFORM_TYPE: StateTrigger,
|
||||
}
|
||||
|
||||
|
||||
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
|
||||
"""Return the triggers for lights."""
|
||||
return TRIGGERS
|
||||
24
homeassistant/components/light/triggers.yaml
Normal file
24
homeassistant/components/light/triggers.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
state:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
state:
|
||||
required: true
|
||||
default: "on"
|
||||
selector:
|
||||
select:
|
||||
options:
|
||||
- "off"
|
||||
- "on"
|
||||
translation_key: state
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: trigger_behavior
|
||||
options:
|
||||
- first
|
||||
- last
|
||||
- any
|
||||
@@ -425,9 +425,10 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="valve_fault_general_fault",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_to_ha=lambda x: (
|
||||
# GeneralFault bit from ValveFault attribute
|
||||
device_to_ha=lambda x: bool(
|
||||
x
|
||||
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
|
||||
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
@@ -443,9 +444,10 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="valve_fault_blocked",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_to_ha=lambda x: (
|
||||
# Blocked bit from ValveFault attribute
|
||||
device_to_ha=lambda x: bool(
|
||||
x
|
||||
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
|
||||
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
@@ -461,9 +463,10 @@ DISCOVERY_SCHEMAS = [
|
||||
translation_key="valve_fault_leaking",
|
||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
device_to_ha=lambda x: (
|
||||
# Leaking bit from ValveFault attribute
|
||||
device_to_ha=lambda x: bool(
|
||||
x
|
||||
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
|
||||
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
|
||||
),
|
||||
),
|
||||
entity_class=MatterBinarySensor,
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pypalazzetti==0.1.19"]
|
||||
"requirements": ["pypalazzetti==0.1.20"]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -17,6 +20,7 @@ from .const import (
|
||||
CONF_ZONE_NUMBER,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DOMAIN,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
@@ -40,9 +44,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry in zone_subentries:
|
||||
zone_num = subentry.data[CONF_ZONE_NUMBER]
|
||||
zone_type = subentry.data[CONF_ZONE_TYPE]
|
||||
zone_name = subentry.data[CONF_NAME]
|
||||
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
|
||||
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||
zone_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
@@ -65,9 +69,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry in output_subentries:
|
||||
output_num = subentry.data[CONF_OUTPUT_NUMBER]
|
||||
ouput_type = subentry.data[CONF_ZONE_TYPE]
|
||||
output_name = subentry.data[CONF_NAME]
|
||||
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
|
||||
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||
output_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
@@ -89,68 +93,48 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
|
||||
"""Representation of an Satel Integra binary sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
device_number,
|
||||
device_name,
|
||||
zone_type,
|
||||
sensor_type,
|
||||
react_to_signal,
|
||||
config_entry_id,
|
||||
):
|
||||
controller: AsyncSatel,
|
||||
device_number: int,
|
||||
device_name: str,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
sensor_type: str,
|
||||
react_to_signal: str,
|
||||
config_entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the binary_sensor."""
|
||||
self._device_number = device_number
|
||||
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
|
||||
self._name = device_name
|
||||
self._zone_type = zone_type
|
||||
self._state = 0
|
||||
self._react_to_signal = react_to_signal
|
||||
self._satel = controller
|
||||
|
||||
self._attr_device_class = device_class
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
|
||||
if self._device_number in self._satel.violated_outputs:
|
||||
self._state = 1
|
||||
else:
|
||||
self._state = 0
|
||||
elif self._device_number in self._satel.violated_zones:
|
||||
self._state = 1
|
||||
self._attr_is_on = self._device_number in self._satel.violated_outputs
|
||||
else:
|
||||
self._state = 0
|
||||
self._attr_is_on = self._device_number in self._satel.violated_zones
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, self._react_to_signal, self._devices_updated
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon for device by its type."""
|
||||
if self._zone_type is BinarySensorDeviceClass.SMOKE:
|
||||
return "mdi:fire"
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state == 1
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._zone_type
|
||||
|
||||
@callback
|
||||
def _devices_updated(self, zones):
|
||||
def _devices_updated(self, zones: dict[int, int]):
|
||||
"""Update the zone's state, if needed."""
|
||||
if self._device_number in zones and self._state != zones[self._device_number]:
|
||||
self._state = zones[self._device_number]
|
||||
self.async_write_ha_state()
|
||||
if self._device_number in zones:
|
||||
new_state = zones[self._device_number] == 1
|
||||
if new_state != self._attr_is_on:
|
||||
self._attr_is_on = new_state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.3.1"]
|
||||
"requirements": ["pysmartthings==3.3.2"]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBitmapBitWrapper, DPCodeBooleanWrapper, DPCodeWrapper
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -366,20 +366,48 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
|
||||
}
|
||||
|
||||
|
||||
def _get_bitmap_bit_mask(
|
||||
device: CustomerDevice, dpcode: str, bitmap_key: str | None
|
||||
) -> int | None:
|
||||
"""Get the bit mask for a given bitmap description."""
|
||||
if (
|
||||
bitmap_key is None
|
||||
or (status_range := device.status_range.get(dpcode)) is None
|
||||
or status_range.type != DPType.BITMAP
|
||||
or not isinstance(bitmap_values := json_loads(status_range.values), dict)
|
||||
or not isinstance(bitmap_labels := bitmap_values.get("label"), list)
|
||||
or bitmap_key not in bitmap_labels
|
||||
):
|
||||
class _CustomDPCodeWrapper(DPCodeWrapper):
|
||||
"""Custom DPCode Wrapper to check for values in a set."""
|
||||
|
||||
_valid_values: set[bool | float | int | str]
|
||||
|
||||
def __init__(
|
||||
self, dpcode: str, valid_values: set[bool | float | int | str]
|
||||
) -> None:
|
||||
"""Init CustomDPCodeBooleanWrapper."""
|
||||
super().__init__(dpcode)
|
||||
self._valid_values = valid_values
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return raw_value in self._valid_values
|
||||
|
||||
|
||||
def _get_dpcode_wrapper(
|
||||
device: CustomerDevice,
|
||||
description: TuyaBinarySensorEntityDescription,
|
||||
) -> DPCodeWrapper | None:
|
||||
"""Get DPCode wrapper for an entity description."""
|
||||
dpcode = description.dpcode or description.key
|
||||
if description.bitmap_key is not None:
|
||||
return DPCodeBitmapBitWrapper.find_dpcode(
|
||||
device, dpcode, bitmap_key=description.bitmap_key
|
||||
)
|
||||
|
||||
if bool_type := DPCodeBooleanWrapper.find_dpcode(device, dpcode):
|
||||
return bool_type
|
||||
|
||||
# Legacy / compatibility
|
||||
if dpcode not in device.status:
|
||||
return None
|
||||
return bitmap_labels.index(bitmap_key)
|
||||
return _CustomDPCodeWrapper(
|
||||
dpcode,
|
||||
description.on_value
|
||||
if isinstance(description.on_value, set)
|
||||
else {description.on_value},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -397,25 +425,11 @@ async def async_setup_entry(
|
||||
for device_id in device_ids:
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := BINARY_SENSORS.get(device.category):
|
||||
for description in descriptions:
|
||||
dpcode = description.dpcode or description.key
|
||||
if dpcode in device.status:
|
||||
mask = _get_bitmap_bit_mask(
|
||||
device, dpcode, description.bitmap_key
|
||||
)
|
||||
|
||||
if (
|
||||
description.bitmap_key is None # Regular binary sensor
|
||||
or mask is not None # Bitmap sensor with valid mask
|
||||
):
|
||||
entities.append(
|
||||
TuyaBinarySensorEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
mask,
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
TuyaBinarySensorEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -436,26 +450,15 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaBinarySensorEntityDescription,
|
||||
bit_mask: int | None = None,
|
||||
dpcode_wrapper: DPCodeWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya binary sensor."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._bit_mask = bit_mask
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if sensor is on."""
|
||||
dpcode = self.entity_description.dpcode or self.entity_description.key
|
||||
if dpcode not in self.device.status:
|
||||
return False
|
||||
|
||||
if self._bit_mask is not None:
|
||||
# For bitmap sensors, check the specific bit mask
|
||||
return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0
|
||||
|
||||
if isinstance(self.entity_description.on_value, set):
|
||||
return self.device.status[dpcode] in self.entity_description.on_value
|
||||
|
||||
return self.device.status[dpcode] == self.entity_description.on_value
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBooleanWrapper
|
||||
|
||||
BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
|
||||
DeviceCategory.HXD: (
|
||||
@@ -21,6 +22,19 @@ BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
|
||||
translation_key="snooze",
|
||||
),
|
||||
),
|
||||
DeviceCategory.MSP: (
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.FACTORY_RESET,
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.MANUAL_CLEAN,
|
||||
translation_key="manual_clean",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.SD: (
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.RESET_DUSTER_CLOTH,
|
||||
@@ -67,9 +81,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := BUTTONS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaButtonEntity(device, manager, description)
|
||||
TuyaButtonEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -89,12 +107,14 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: ButtonEntityDescription,
|
||||
dpcode_wrapper: DPCodeBooleanWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya button."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
def press(self) -> None:
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": True}])
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
|
||||
|
||||
@@ -717,6 +717,7 @@ class DPCode(StrEnum):
|
||||
ELECTRICITY_LEFT = "electricity_left"
|
||||
EXCRETION_TIME_DAY = "excretion_time_day"
|
||||
EXCRETION_TIMES_DAY = "excretion_times_day"
|
||||
FACTORY_RESET = "factory_reset"
|
||||
FAN_BEEP = "fan_beep" # Sound
|
||||
FAN_COOL = "fan_cool" # Cool wind
|
||||
FAN_DIRECTION = "fan_direction" # Fan direction
|
||||
@@ -773,6 +774,7 @@ class DPCode(StrEnum):
|
||||
LIQUID_STATE = "liquid_state"
|
||||
LOCK = "lock" # Lock / Child lock
|
||||
MACH_OPERATE = "mach_operate"
|
||||
MANUAL_CLEAN = "manual_clean"
|
||||
MANUAL_FEED = "manual_feed"
|
||||
MASTER_MODE = "master_mode" # alarm mode
|
||||
MASTER_STATE = "master_state" # alarm state
|
||||
|
||||
@@ -240,6 +240,13 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
|
||||
color_data=DPCode.COLOUR_DATA,
|
||||
),
|
||||
),
|
||||
DeviceCategory.MSP: (
|
||||
TuyaLightEntityDescription(
|
||||
key=DPCode.LIGHT,
|
||||
translation_key="light",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.QJDCZ: (
|
||||
TuyaLightEntityDescription(
|
||||
key=DPCode.SWITCH_LED,
|
||||
|
||||
@@ -22,17 +22,18 @@ class TypeInformation:
|
||||
As provided by the SDK, from `device.function` / `device.status_range`.
|
||||
"""
|
||||
|
||||
dpcode: DPCode
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a TypeInformation object."""
|
||||
raise NotImplementedError("from_json is not implemented for this type")
|
||||
return cls(dpcode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegerTypeData(TypeInformation):
|
||||
"""Integer Type Data."""
|
||||
|
||||
dpcode: DPCode
|
||||
min: int
|
||||
max: int
|
||||
scale: float
|
||||
@@ -100,11 +101,24 @@ class IntegerTypeData(TypeInformation):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BitmapTypeInformation(TypeInformation):
|
||||
"""Bitmap type information."""
|
||||
|
||||
label: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := json.loads(data)):
|
||||
return None
|
||||
return cls(dpcode, **parsed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnumTypeData(TypeInformation):
|
||||
"""Enum Type Data."""
|
||||
|
||||
dpcode: DPCode
|
||||
range: list[str]
|
||||
|
||||
@classmethod
|
||||
@@ -116,6 +130,8 @@ class EnumTypeData(TypeInformation):
|
||||
|
||||
|
||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BITMAP: BitmapTypeInformation,
|
||||
DPType.BOOLEAN: TypeInformation,
|
||||
DPType.ENUM: EnumTypeData,
|
||||
DPType.INTEGER: IntegerTypeData,
|
||||
}
|
||||
@@ -146,13 +162,13 @@ class DPCodeWrapper(ABC):
|
||||
The raw device status is converted to a Home Assistant value.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value.
|
||||
|
||||
This is called by `get_update_command` to prepare the value for sending
|
||||
back to the device, and should be implemented in concrete classes.
|
||||
back to the device, and should be implemented in concrete classes if needed.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
|
||||
"""Get the update command for the dpcode.
|
||||
@@ -165,29 +181,6 @@ class DPCodeWrapper(ABC):
|
||||
}
|
||||
|
||||
|
||||
class DPCodeBooleanWrapper(DPCodeWrapper):
|
||||
"""Simple wrapper for boolean values.
|
||||
|
||||
Supports True/False only.
|
||||
"""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) in (True, False):
|
||||
return raw_value
|
||||
return None
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: Any
|
||||
) -> Any | None:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
if value in (True, False):
|
||||
return value
|
||||
# Currently only called with boolean values
|
||||
# Safety net in case of future changes
|
||||
raise ValueError(f"Invalid boolean value `{value}`")
|
||||
|
||||
|
||||
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
"""Base DPCode wrapper with Type Information."""
|
||||
|
||||
@@ -217,6 +210,31 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
return None
|
||||
|
||||
|
||||
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Simple wrapper for boolean values.
|
||||
|
||||
Supports True/False only.
|
||||
"""
|
||||
|
||||
DPTYPE = DPType.BOOLEAN
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) in (True, False):
|
||||
return raw_value
|
||||
return None
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: Any
|
||||
) -> Any | None:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
if value in (True, False):
|
||||
return value
|
||||
# Currently only called with boolean values
|
||||
# Safety net in case of future changes
|
||||
raise ValueError(f"Invalid boolean value `{value}`")
|
||||
|
||||
|
||||
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Simple wrapper for EnumTypeData values."""
|
||||
|
||||
@@ -272,6 +290,48 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
)
|
||||
|
||||
|
||||
class DPCodeBitmapBitWrapper(DPCodeWrapper):
|
||||
"""Simple wrapper for a specific bit in bitmap values."""
|
||||
|
||||
def __init__(self, dpcode: str, mask: int) -> None:
|
||||
"""Init DPCodeBitmapWrapper."""
|
||||
super().__init__(dpcode)
|
||||
self._mask = mask
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return (raw_value & (1 << self._mask)) != 0
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(
|
||||
cls,
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...],
|
||||
*,
|
||||
bitmap_key: str,
|
||||
) -> Self | None:
|
||||
"""Find and return a DPCodeBitmapBitWrapper for the given DP codes."""
|
||||
if (
|
||||
type_information := find_dpcode(device, dpcodes, dptype=DPType.BITMAP)
|
||||
) and bitmap_key in type_information.label:
|
||||
return cls(
|
||||
type_information.dpcode, type_information.label.index(bitmap_key)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BITMAP],
|
||||
) -> BitmapTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"factory_reset": {
|
||||
"name": "Factory reset"
|
||||
},
|
||||
"manual_clean": {
|
||||
"name": "Manual clean"
|
||||
},
|
||||
"reset_duster_cloth": {
|
||||
"name": "Reset duster cloth"
|
||||
},
|
||||
|
||||
@@ -946,14 +946,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SWITCHES.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSwitchEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
DPCodeBooleanWrapper(description.key),
|
||||
)
|
||||
TuyaSwitchEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
and _check_deprecation(
|
||||
hass,
|
||||
device,
|
||||
|
||||
@@ -94,14 +94,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := VALVES.get(device.category):
|
||||
entities.extend(
|
||||
TuyaValveEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
DPCodeBooleanWrapper(description.key),
|
||||
)
|
||||
TuyaValveEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -35,6 +35,7 @@ class VeluxLight(VeluxEntity, LightEntity):
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_name = None
|
||||
|
||||
node: LighteningDevice
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -65,7 +66,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bo
|
||||
async def _async_auth_and_create_api(
|
||||
hass: HomeAssistant, entry: VolvoConfigEntry
|
||||
) -> VolvoCarsApi:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
oauth_session = OAuth2Session(hass, entry, implementation)
|
||||
web_session = async_get_clientsession(hass)
|
||||
auth = VolvoAuth(web_session, oauth_session)
|
||||
|
||||
@@ -362,6 +362,9 @@
|
||||
"no_vehicle": {
|
||||
"message": "Unable to retrieve vehicle details."
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
},
|
||||
"unauthorized": {
|
||||
"message": "Authentication failed. {message}"
|
||||
},
|
||||
|
||||
@@ -806,6 +806,9 @@ async def async_get_all_descriptions(
|
||||
|
||||
description = {"fields": yaml_description.get("fields", {})}
|
||||
|
||||
if (target := yaml_description.get("target")) is not None:
|
||||
description["target"] = target
|
||||
|
||||
new_descriptions_cache[missing_trigger] = description
|
||||
|
||||
hass.data[TRIGGER_DESCRIPTION_CACHE] = new_descriptions_cache
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -2259,7 +2259,7 @@ pyotp==2.9.0
|
||||
pyoverkiz==1.19.0
|
||||
|
||||
# homeassistant.components.palazzetti
|
||||
pypalazzetti==0.1.19
|
||||
pypalazzetti==0.1.20
|
||||
|
||||
# homeassistant.components.paperless_ngx
|
||||
pypaperless==4.1.1
|
||||
@@ -2380,7 +2380,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.3.1
|
||||
pysmartthings==3.3.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -1885,7 +1885,7 @@ pyotp==2.9.0
|
||||
pyoverkiz==1.19.0
|
||||
|
||||
# homeassistant.components.palazzetti
|
||||
pypalazzetti==0.1.19
|
||||
pypalazzetti==0.1.20
|
||||
|
||||
# homeassistant.components.paperless_ngx
|
||||
pypaperless==4.1.1
|
||||
@@ -1982,7 +1982,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.3.1
|
||||
pysmartthings==3.3.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Test Ambient Weather Network sensors."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aioambient import OpenAPI
|
||||
from aioambient.errors import RequestError
|
||||
from freezegun import freeze_time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -18,7 +18,7 @@ from .conftest import setup_platform
|
||||
from tests.common import async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@freeze_time("2023-11-9")
|
||||
@pytest.mark.freeze_time("2023-11-9")
|
||||
@pytest.mark.parametrize(
|
||||
"config_entry",
|
||||
["AA:AA:AA:AA:AA:AA", "CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD"],
|
||||
@@ -54,45 +54,43 @@ async def test_sensors_with_no_data(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True)
|
||||
@pytest.mark.freeze_time("2023-11-8")
|
||||
async def test_sensors_disappearing(
|
||||
hass: HomeAssistant,
|
||||
open_api: OpenAPI,
|
||||
aioambient: AsyncMock,
|
||||
config_entry: ConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that we log errors properly."""
|
||||
|
||||
initial_datetime = datetime(year=2023, month=11, day=8)
|
||||
with freeze_time(initial_datetime) as frozen_datetime:
|
||||
# Normal state, sensor is available.
|
||||
await setup_platform(True, hass, config_entry)
|
||||
# Normal state, sensor is available.
|
||||
await setup_platform(True, hass, config_entry)
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert float(sensor.state) == pytest.approx(1001.89694313129)
|
||||
|
||||
# Sensor becomes unavailable if the network is unavailable. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
with patch.object(open_api, "get_device_details", side_effect=RequestError()):
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "unavailable"
|
||||
assert caplog.text.count("Cannot connect to Ambient Network") == 1
|
||||
|
||||
# Network comes back. Sensor should start reporting again. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert float(sensor.state) == pytest.approx(1001.89694313129)
|
||||
|
||||
# Sensor becomes unavailable if the network is unavailable. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
with patch.object(
|
||||
open_api, "get_device_details", side_effect=RequestError()
|
||||
):
|
||||
frozen_datetime.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "unavailable"
|
||||
assert caplog.text.count("Cannot connect to Ambient Network") == 1
|
||||
|
||||
# Network comes back. Sensor should start reporting again. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
frozen_datetime.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert float(sensor.state) == pytest.approx(1001.89694313129)
|
||||
assert caplog.text.count("Fetching ambient_network data recovered") == 1
|
||||
assert caplog.text.count("Fetching ambient_network data recovered") == 1
|
||||
|
||||
@@ -1 +1,168 @@
|
||||
"""Tests for the Anthropic integration."""
|
||||
|
||||
from anthropic.types import (
|
||||
CitationsDelta,
|
||||
InputJSONDelta,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageStreamEvent,
|
||||
RedactedThinkingBlock,
|
||||
ServerToolUseBlock,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextCitation,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingDelta,
|
||||
ToolUseBlock,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultBlock,
|
||||
)
|
||||
|
||||
|
||||
def create_content_block(
|
||||
index: int, text_parts: list[str], citations: list[TextCitation] | None = None
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a text content block with the specified deltas."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=TextBlock(
|
||||
text="", type="text", citations=[] if citations else None
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=CitationsDelta(citation=citation, type="citations_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for citation in (citations or [])
|
||||
],
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=TextDelta(text=text_part, type="text_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for text_part in text_parts
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_thinking_block(
|
||||
index: int, thinking_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a thinking block with the specified deltas."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ThinkingBlock(signature="", thinking="", type="thinking"),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=ThinkingDelta(thinking=thinking_part, type="thinking_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for thinking_part in thinking_parts
|
||||
],
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=SignatureDelta(
|
||||
signature="ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/N"
|
||||
"oB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ"
|
||||
"4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo"
|
||||
"21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==",
|
||||
type="signature_delta",
|
||||
),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_redacted_thinking_block(index: int) -> list[RawMessageStreamEvent]:
|
||||
"""Create a redacted thinking block."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=RedactedThinkingBlock(
|
||||
data="EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9K"
|
||||
"WPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeV"
|
||||
"sJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOK"
|
||||
"iKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny",
|
||||
type="redacted_thinking",
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_tool_use_block(
|
||||
index: int, tool_id: str, tool_name: str, json_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a tool use content block with the specified deltas."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ToolUseBlock(
|
||||
id=tool_id, name=tool_name, input={}, type="tool_use"
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for json_part in json_parts
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_web_search_block(
|
||||
index: int, id: str, query_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool use block for web search."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ServerToolUseBlock(
|
||||
type="server_tool_use", id=id, input={}, name="web_search"
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=InputJSONDelta(type="input_json_delta", partial_json=query_part),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for query_part in query_parts
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_web_search_result_block(
|
||||
index: int, id: str, results: list[WebSearchResultBlock]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool result block for web search results."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=WebSearchToolResultBlock(
|
||||
type="web_search_tool_result", tool_use_id=id, content=results
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
"""Tests helpers."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import patch
|
||||
from collections.abc import AsyncGenerator, Generator, Iterable
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from anthropic.types import (
|
||||
Message,
|
||||
MessageDeltaUsage,
|
||||
RawContentBlockStartEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RawMessageStreamEvent,
|
||||
ToolUseBlock,
|
||||
Usage,
|
||||
)
|
||||
from anthropic.types.raw_message_delta_event import Delta
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.anthropic import CONF_CHAT_MODEL
|
||||
@@ -14,6 +26,7 @@ from homeassistant.components.anthropic.const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
)
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
@@ -40,7 +53,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"subentry_type": "conversation",
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
@@ -114,3 +133,61 @@ async def mock_init_component(
|
||||
async def setup_ha(hass: HomeAssistant) -> None:
|
||||
"""Set up Home Assistant."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_create_stream() -> Generator[AsyncMock]:
|
||||
"""Mock stream response."""
|
||||
|
||||
async def mock_generator(events: Iterable[RawMessageStreamEvent], **kwargs):
|
||||
"""Create a stream of messages with the specified content blocks."""
|
||||
stop_reason = "end_turn"
|
||||
refusal_magic_string = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86"
|
||||
for message in kwargs.get("messages"):
|
||||
if message["role"] != "user":
|
||||
continue
|
||||
if isinstance(message["content"], str):
|
||||
if refusal_magic_string in message["content"]:
|
||||
stop_reason = "refusal"
|
||||
break
|
||||
else:
|
||||
for content in message["content"]:
|
||||
if content.get(
|
||||
"type"
|
||||
) == "text" and refusal_magic_string in content.get("text", ""):
|
||||
stop_reason = "refusal"
|
||||
break
|
||||
|
||||
yield RawMessageStartEvent(
|
||||
message=Message(
|
||||
type="message",
|
||||
id="msg_1234567890ABCDEFGHIJKLMN",
|
||||
content=[],
|
||||
role="assistant",
|
||||
model="claude-3-5-sonnet-20240620",
|
||||
usage=Usage(input_tokens=0, output_tokens=0),
|
||||
),
|
||||
type="message_start",
|
||||
)
|
||||
for event in events:
|
||||
if isinstance(event, RawContentBlockStartEvent) and isinstance(
|
||||
event.content_block, ToolUseBlock
|
||||
):
|
||||
stop_reason = "tool_use"
|
||||
yield event
|
||||
yield RawMessageDeltaEvent(
|
||||
type="message_delta",
|
||||
delta=Delta(stop_reason=stop_reason, stop_sequence=""),
|
||||
usage=MessageDeltaUsage(output_tokens=0),
|
||||
)
|
||||
yield RawMessageStopEvent(type="message_stop")
|
||||
|
||||
with patch(
|
||||
"anthropic.resources.messages.AsyncMessages.create",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create:
|
||||
mock_create.side_effect = lambda **kwargs: mock_generator(
|
||||
mock_create.return_value.pop(0), **kwargs
|
||||
)
|
||||
|
||||
yield mock_create
|
||||
|
||||
211
tests/components/anthropic/test_ai_task.py
Normal file
211
tests/components/anthropic/test_ai_task.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for the Anthropic integration."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ai_task, media_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from . import create_content_block, create_tool_use_block
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_generate_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test AI Task data generation."""
|
||||
entity_id = "ai_task.claude_ai_task"
|
||||
|
||||
# Ensure entity is linked to the subentry
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
ai_task_entry = next(
|
||||
iter(
|
||||
entry
|
||||
for entry in mock_config_entry.subentries.values()
|
||||
if entry.subentry_type == "ai_task_data"
|
||||
)
|
||||
)
|
||||
assert entity_entry is not None
|
||||
assert entity_entry.config_entry_id == mock_config_entry.entry_id
|
||||
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
|
||||
|
||||
mock_create_stream.return_value = [create_content_block(0, ["The test data"])]
|
||||
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Generate test data",
|
||||
)
|
||||
|
||||
assert result.data == "The test data"
|
||||
|
||||
|
||||
async def test_generate_structured_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test AI Task structured data generation."""
|
||||
mock_create_stream.return_value = [
|
||||
create_tool_use_block(
|
||||
1,
|
||||
"toolu_0123456789AbCdEfGhIjKlM",
|
||||
"test_task",
|
||||
['{"charac', 'ters": ["Mario', '", "Luigi"]}'],
|
||||
),
|
||||
]
|
||||
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.Required("characters"): selector.selector(
|
||||
{
|
||||
"text": {
|
||||
"multiple": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert result.data == {"characters": ["Mario", "Luigi"]}
|
||||
|
||||
|
||||
async def test_generate_invalid_structured_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test AI Task with invalid JSON response."""
|
||||
mock_create_stream.return_value = [
|
||||
create_tool_use_block(
|
||||
1,
|
||||
"toolu_0123456789AbCdEfGhIjKlM",
|
||||
"test_task",
|
||||
"INVALID JSON RESPONSE",
|
||||
)
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="Error with Claude structured response"
|
||||
):
|
||||
await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.Required("characters"): selector.selector(
|
||||
{
|
||||
"text": {
|
||||
"multiple": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_generate_data_with_attachments(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test AI Task data generation with attachments."""
|
||||
entity_id = "ai_task.claude_ai_task"
|
||||
|
||||
mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])]
|
||||
|
||||
# Test with attachments
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.media_source.async_resolve_media",
|
||||
side_effect=[
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/doorbell_snapshot.jpg",
|
||||
mime_type="image/jpeg",
|
||||
path=Path("doorbell_snapshot.jpg"),
|
||||
),
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/context.pdf",
|
||||
mime_type="application/pdf",
|
||||
path=Path("context.pdf"),
|
||||
),
|
||||
],
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch(
|
||||
"homeassistant.components.openai_conversation.entity.guess_file_type",
|
||||
return_value=("image/jpeg", None),
|
||||
),
|
||||
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
|
||||
):
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Test prompt",
|
||||
attachments=[
|
||||
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
|
||||
{"media_content_id": "media-source://media/context.pdf"},
|
||||
],
|
||||
)
|
||||
|
||||
assert result.data == "Hi there!"
|
||||
|
||||
# Verify that the create stream was called with the correct parameters
|
||||
# The last call should have the user message with attachments
|
||||
call_args = mock_create_stream.call_args
|
||||
assert call_args is not None
|
||||
|
||||
# Check that the input includes the attachments
|
||||
input_messages = call_args[1]["messages"]
|
||||
assert len(input_messages) > 0
|
||||
|
||||
# Find the user message with attachments
|
||||
user_message_with_attachments = input_messages[-2]
|
||||
|
||||
assert user_message_with_attachments is not None
|
||||
assert isinstance(user_message_with_attachments["content"], list)
|
||||
assert len(user_message_with_attachments["content"]) == 3 # Text + attachments
|
||||
assert user_message_with_attachments["content"] == [
|
||||
{"type": "text", "text": "Test prompt"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "image/jpeg",
|
||||
"type": "base64",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "application/pdf",
|
||||
"type": "base64",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -15,7 +15,10 @@ from httpx import URL, Request, Response
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.anthropic.config_flow import RECOMMENDED_OPTIONS
|
||||
from homeassistant.components.anthropic.config_flow import (
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
)
|
||||
from homeassistant.components.anthropic.const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
@@ -30,6 +33,7 @@ from homeassistant.components.anthropic.const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
@@ -74,7 +78,6 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
"api_key": "bla",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["data"] == {
|
||||
@@ -84,10 +87,16 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
assert result2["subentries"] == [
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_OPTIONS,
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -135,14 +144,13 @@ async def test_creating_conversation_subentry(
|
||||
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS},
|
||||
{CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "Mock name"
|
||||
|
||||
processed_options = RECOMMENDED_OPTIONS.copy()
|
||||
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
||||
|
||||
assert result2["data"] == processed_options
|
||||
@@ -302,7 +310,6 @@ async def test_subentry_web_search_user_location(
|
||||
"user_location": True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
mock_create.call_args.kwargs["messages"][0]["content"] == "Where are the "
|
||||
@@ -557,3 +564,122 @@ async def test_subentry_options_switching(
|
||||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||
assert subentry.data == expected_options
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry."""
|
||||
old_subentries = set(mock_config_entry.subentries)
|
||||
# Original conversation + original ai_task
|
||||
assert len(mock_config_entry.subentries) == 2
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "init"
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"name": "Custom AI Task",
|
||||
CONF_RECOMMENDED: True,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert result2.get("title") == "Custom AI Task"
|
||||
assert result2.get("data") == {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
assert (
|
||||
len(mock_config_entry.subentries) == 3
|
||||
) # Original conversation + original ai_task + new ai_task
|
||||
|
||||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||||
assert new_subentry.subentry_type == "ai_task_data"
|
||||
assert new_subentry.title == "Custom AI Task"
|
||||
|
||||
|
||||
async def test_ai_task_subentry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry when entry is not loaded."""
|
||||
# Don't call mock_init_component to simulate not loaded state
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.ABORT
|
||||
assert result.get("reason") == "entry_not_loaded"
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry_advanced(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry with advanced settings."""
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "init"
|
||||
|
||||
# Go to advanced settings
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"name": "Advanced AI Task",
|
||||
CONF_RECOMMENDED: False,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2.get("type") is FlowResultType.FORM
|
||||
assert result2.get("step_id") == "advanced"
|
||||
|
||||
# Configure advanced settings
|
||||
result3 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-5",
|
||||
CONF_MAX_TOKENS: 200,
|
||||
CONF_TEMPERATURE: 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
assert result3.get("type") is FlowResultType.FORM
|
||||
assert result3.get("step_id") == "model"
|
||||
|
||||
# Configure model settings
|
||||
result4 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_WEB_SEARCH: False,
|
||||
},
|
||||
)
|
||||
|
||||
assert result4.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert result4.get("title") == "Advanced AI Task"
|
||||
assert result4.get("data") == {
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-5",
|
||||
CONF_MAX_TOKENS: 200,
|
||||
CONF_TEMPERATURE: 0.5,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
203
tests/components/light/test_condition.py
Normal file
203
tests/components/light/test_condition.py
Normal file
@@ -0,0 +1,203 @@
|
||||
"""Test light conditions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import (
|
||||
ATTR_LABEL_ID,
|
||||
CONF_CONDITION,
|
||||
CONF_OPTIONS,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNAVAILABLE,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import entity_registry as er, label_registry as lr
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def label_entities(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple entities associated with labels."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
config_entry = MockConfigEntry(domain="test_labels")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Label")
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
|
||||
for i in range(3):
|
||||
light_entity = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id=f"label_light_{i}",
|
||||
suggested_object_id=f"label_light_{i}",
|
||||
)
|
||||
entity_reg.async_update_entity(light_entity.entity_id, labels={label.label_id})
|
||||
|
||||
# Also create switches to test that they don't impact the conditions
|
||||
for i in range(2):
|
||||
switch_entity = entity_reg.async_get_or_create(
|
||||
domain="switch",
|
||||
platform="test",
|
||||
unique_id=f"label_switch_{i}",
|
||||
suggested_object_id=f"label_switch_{i}",
|
||||
)
|
||||
entity_reg.async_update_entity(switch_entity.entity_id, labels={label.label_id})
|
||||
|
||||
return [
|
||||
"light.label_light_0",
|
||||
"light.label_light_1",
|
||||
"light.label_light_2",
|
||||
]
|
||||
|
||||
|
||||
async def setup_automation_with_light_condition(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
condition: str,
|
||||
target: dict,
|
||||
behavior: str,
|
||||
) -> None:
|
||||
"""Set up automation with light state condition."""
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {"platform": "event", "event_type": "test_event"},
|
||||
"condition": {
|
||||
CONF_CONDITION: condition,
|
||||
CONF_TARGET: target,
|
||||
CONF_OPTIONS: {"behavior": behavior},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def has_calls_after_trigger(
|
||||
hass: HomeAssistant, service_calls: list[ServiceCall]
|
||||
) -> bool:
|
||||
"""Check if there are service calls after the trigger event."""
|
||||
hass.bus.async_fire("test_event")
|
||||
await hass.async_block_till_done()
|
||||
has_calls = len(service_calls) == 1
|
||||
service_calls.clear()
|
||||
return has_calls
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "state", "reverse_state"),
|
||||
[("light.state_on", STATE_ON, STATE_OFF), ("light.state_off", STATE_OFF, STATE_ON)],
|
||||
)
|
||||
async def test_light_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
label_entities: list[str],
|
||||
condition: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'any' behavior."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for entity_id in label_entities:
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target={ATTR_LABEL_ID: "test_label", "entity_id": "light.nonexistent"},
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
# No lights on the condition state
|
||||
assert not await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set one light to the condition state -> condition pass
|
||||
hass.states.async_set(label_entities[0], state)
|
||||
assert await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set all lights to the condition state -> condition pass
|
||||
for entity_id in label_entities:
|
||||
hass.states.async_set(entity_id, state)
|
||||
assert await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set one light to unavailable -> condition pass
|
||||
hass.states.async_set(label_entities[0], STATE_UNAVAILABLE)
|
||||
assert await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set all lights to unavailable -> condition fail
|
||||
for entity_id in label_entities:
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
assert not await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "state", "reverse_state"),
|
||||
[("light.state_on", STATE_ON, STATE_OFF), ("light.state_off", STATE_OFF, STATE_ON)],
|
||||
)
|
||||
async def test_light_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
label_entities: list[str],
|
||||
condition: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test the light state condition with the 'all' behavior."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
# Set state for two switches to ensure that they don't impact the condition
|
||||
hass.states.async_set("switch.label_switch_1", STATE_OFF)
|
||||
hass.states.async_set("switch.label_switch_2", STATE_ON)
|
||||
|
||||
for entity_id in label_entities:
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
|
||||
await setup_automation_with_light_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target={ATTR_LABEL_ID: "test_label", "entity_id": "light.nonexistent"},
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
# No lights on the condition state
|
||||
assert not await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set one light to the condition state -> condition fail
|
||||
hass.states.async_set(label_entities[0], state)
|
||||
assert not await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set all lights to the condition state -> condition pass
|
||||
for entity_id in label_entities:
|
||||
hass.states.async_set(entity_id, state)
|
||||
assert await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set one light to unavailable -> condition still pass
|
||||
hass.states.async_set(label_entities[0], STATE_UNAVAILABLE)
|
||||
assert await has_calls_after_trigger(hass, service_calls)
|
||||
|
||||
# Set all lights to unavailable -> condition passes
|
||||
for entity_id in label_entities:
|
||||
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
|
||||
assert await has_calls_after_trigger(hass, service_calls)
|
||||
283
tests/components/light/test_trigger.py
Normal file
283
tests/components/light/test_trigger.py
Normal file
@@ -0,0 +1,283 @@
|
||||
"""Test light trigger."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation
|
||||
from homeassistant.const import (
|
||||
ATTR_AREA_ID,
|
||||
ATTR_DEVICE_ID,
|
||||
ATTR_FLOOR_ID,
|
||||
ATTR_LABEL_ID,
|
||||
CONF_ENTITY_ID,
|
||||
CONF_PLATFORM,
|
||||
CONF_STATE,
|
||||
CONF_TARGET,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
label_registry as lr,
|
||||
)
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, mock_device_registry
|
||||
|
||||
# remove when #151314 is merged
|
||||
CONF_OPTIONS = "options"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_lights(hass: HomeAssistant) -> None:
|
||||
"""Create multiple light entities associated with different targets."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
config_entry = MockConfigEntry(domain="test")
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
floor_reg = fr.async_get(hass)
|
||||
floor = floor_reg.async_create("Test Floor")
|
||||
|
||||
area_reg = ar.async_get(hass)
|
||||
area = area_reg.async_create("Test Area", floor_id=floor.floor_id)
|
||||
|
||||
label_reg = lr.async_get(hass)
|
||||
label = label_reg.async_create("Test Label")
|
||||
|
||||
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
|
||||
mock_device_registry(hass, {device.id: device})
|
||||
|
||||
entity_reg = er.async_get(hass)
|
||||
# Light associated with area
|
||||
light_area = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_area",
|
||||
suggested_object_id="area_light",
|
||||
)
|
||||
entity_reg.async_update_entity(light_area.entity_id, area_id=area.id)
|
||||
|
||||
# Light associated with device
|
||||
entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_device",
|
||||
suggested_object_id="device_light",
|
||||
device_id=device.id,
|
||||
)
|
||||
|
||||
# Light associated with label
|
||||
light_label = entity_reg.async_get_or_create(
|
||||
domain="light",
|
||||
platform="test",
|
||||
unique_id="light_label",
|
||||
suggested_object_id="label_light",
|
||||
)
|
||||
entity_reg.async_update_entity(light_label.entity_id, labels={label.label_id})
|
||||
|
||||
# Return all available light entities
|
||||
return [
|
||||
"light.standalone_light",
|
||||
"light.label_light",
|
||||
"light.area_light",
|
||||
"light.device_light",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("target_lights")
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when any light state changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
hass.states.async_set(entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_first(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when the first light changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state, "behavior": "first"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
service_calls.clear()
|
||||
|
||||
# Triggering other lights should not cause any service calls after the first one
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
assert service_calls[0].data[CONF_ENTITY_ID] == entity_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("trigger_target_config", "entity_id"),
|
||||
[
|
||||
({CONF_ENTITY_ID: "light.standalone_light"}, "light.standalone_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.label_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.area_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.area_light"),
|
||||
({ATTR_LABEL_ID: "test_label"}, "light.device_light"),
|
||||
({ATTR_AREA_ID: "test_area"}, "light.device_light"),
|
||||
({ATTR_FLOOR_ID: "test_floor"}, "light.device_light"),
|
||||
({ATTR_DEVICE_ID: "test_device"}, "light.device_light"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("state", "reverse_state"), [(STATE_ON, STATE_OFF), (STATE_OFF, STATE_ON)]
|
||||
)
|
||||
async def test_light_state_trigger_behavior_last(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
trigger_target_config: dict,
|
||||
entity_id: str,
|
||||
state: str,
|
||||
reverse_state: str,
|
||||
) -> None:
|
||||
"""Test that the light state trigger fires when the last light changes to a specific state."""
|
||||
await async_setup_component(hass, "light", {})
|
||||
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, reverse_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"trigger": {
|
||||
CONF_PLATFORM: "light.state",
|
||||
CONF_TARGET: {**trigger_target_config},
|
||||
CONF_OPTIONS: {CONF_STATE: state, "behavior": "last"},
|
||||
},
|
||||
"action": {
|
||||
"service": "test.automation",
|
||||
"data_template": {CONF_ENTITY_ID: f"{entity_id}"},
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
target_lights.remove(entity_id)
|
||||
for other_entity_id in target_lights:
|
||||
hass.states.async_set(other_entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 0
|
||||
|
||||
hass.states.async_set(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
assert len(service_calls) == 1
|
||||
@@ -298,7 +298,7 @@ async def test_water_valve(
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
|
||||
# ValveFault general_fault test
|
||||
# ValveFault general_fault test (bit 0)
|
||||
set_node_attribute(matter_node, 1, 129, 9, 1)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
||||
@@ -314,7 +314,7 @@ async def test_water_valve(
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
|
||||
# ValveFault valve_blocked test
|
||||
# ValveFault valve_blocked test (bit 1)
|
||||
set_node_attribute(matter_node, 1, 129, 9, 2)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
||||
@@ -330,7 +330,7 @@ async def test_water_valve(
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
|
||||
# ValveFault valve_leaking test
|
||||
# ValveFault valve_leaking test (bit 2)
|
||||
set_node_attribute(matter_node, 1, 129, 9, 4)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
||||
@@ -346,6 +346,22 @@ async def test_water_valve(
|
||||
assert state
|
||||
assert state.state == "on"
|
||||
|
||||
# ValveFault multiple faults test (bits 0 and 2)
|
||||
set_node_attribute(matter_node, 1, 129, 9, 5)
|
||||
await trigger_subscription_callback(hass, matter_client)
|
||||
|
||||
state = hass.states.get("binary_sensor.valve_general_fault")
|
||||
assert state
|
||||
assert state.state == "on"
|
||||
|
||||
state = hass.states.get("binary_sensor.valve_valve_blocked")
|
||||
assert state
|
||||
assert state.state == "off"
|
||||
|
||||
state = hass.states.get("binary_sensor.valve_valve_leaking")
|
||||
assert state
|
||||
assert state.state == "on"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("node_fixture", ["thermostat"])
|
||||
async def test_thermostat_occupancy(
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
"dhw_cm_switch": false
|
||||
}
|
||||
},
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Emma Pro",
|
||||
"model_id": "170-01",
|
||||
"name": "Emma",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.0,
|
||||
"setpoint": 23.5,
|
||||
"temperature": 24.2
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.0,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "60EFABFFFE89CBA0"
|
||||
},
|
||||
"1772a4ea304041adb83f357b751341ff": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
@@ -38,11 +64,11 @@
|
||||
"model_id": "106-03",
|
||||
"name": "Tom Badkamer",
|
||||
"sensors": {
|
||||
"battery": 99,
|
||||
"setpoint": 18.0,
|
||||
"temperature": 21.6,
|
||||
"temperature_difference": -0.2,
|
||||
"valve_position": 100
|
||||
"battery": 60,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 24.8,
|
||||
"temperature_difference": -0.4,
|
||||
"valve_position": 100.0
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
@@ -51,10 +77,9 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C8FF5EE"
|
||||
"zigbee_mac_address": "000D6F000C8FCBA0"
|
||||
},
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
|
||||
"available": true,
|
||||
"dev_class": "thermostat",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "ThermoTouch",
|
||||
@@ -62,7 +87,7 @@
|
||||
"name": "Anna",
|
||||
"sensors": {
|
||||
"setpoint": 23.5,
|
||||
"temperature": 25.8
|
||||
"temperature": 24.0
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
@@ -71,20 +96,20 @@
|
||||
"plugwise_notification": false
|
||||
},
|
||||
"dev_class": "gateway",
|
||||
"firmware": "3.7.8",
|
||||
"firmware": "3.9.0",
|
||||
"gateway_modes": ["away", "full", "vacation"],
|
||||
"hardware": "AME Smile 2.0 board",
|
||||
"location": "bc93488efab249e5bc54fd7e175a6f91",
|
||||
"mac_address": "012345679891",
|
||||
"mac_address": "D40FB201CBA0",
|
||||
"model": "Gateway",
|
||||
"model_id": "smile_open_therm",
|
||||
"name": "Adam",
|
||||
"notifications": {},
|
||||
"regulation_modes": [
|
||||
"bleeding_hot",
|
||||
"bleeding_cold",
|
||||
"off",
|
||||
"heating",
|
||||
"off",
|
||||
"bleeding_hot",
|
||||
"cooling"
|
||||
],
|
||||
"select_gateway_mode": "full",
|
||||
@@ -93,12 +118,33 @@
|
||||
"outdoor_temperature": 29.65
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000D5A168D"
|
||||
"zigbee_mac_address": "000D6F000D5ACBA0"
|
||||
},
|
||||
"da575e9e09b947e281fb6e3ebce3b174": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermometer",
|
||||
"firmware": "2020-09-01T02:00:00+02:00",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Jip",
|
||||
"model_id": "168-01",
|
||||
"name": "Jip",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.8,
|
||||
"setpoint": 23.5,
|
||||
"temperature": 23.8
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "70AC08FFFEE1CBA0"
|
||||
},
|
||||
"e2f4322d57924fa090fbbc48b3a140dc": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": true
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"firmware": "2016-10-10T02:00:00+02:00",
|
||||
@@ -108,9 +154,9 @@
|
||||
"model_id": "158-01",
|
||||
"name": "Lisa Badkamer",
|
||||
"sensors": {
|
||||
"battery": 14,
|
||||
"setpoint": 23.5,
|
||||
"temperature": 23.9
|
||||
"battery": 71,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 25.6
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
@@ -119,7 +165,7 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C869B61"
|
||||
"zigbee_mac_address": "000D6F000C86CBA0"
|
||||
},
|
||||
"e8ef2a01ed3b4139a53bf749204fe6b4": {
|
||||
"dev_class": "switching",
|
||||
@@ -138,9 +184,9 @@
|
||||
"active_preset": "home",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "cool",
|
||||
@@ -148,12 +194,12 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Living room",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 149.9,
|
||||
"electricity_consumed": 60.8,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 25.8
|
||||
"temperature": 24.2
|
||||
},
|
||||
"thermostat": {
|
||||
"lower_bound": 1.0,
|
||||
@@ -162,18 +208,22 @@
|
||||
"upper_bound": 35.0
|
||||
},
|
||||
"thermostats": {
|
||||
"primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"],
|
||||
"primary": [
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8",
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6",
|
||||
"da575e9e09b947e281fb6e3ebce3b174"
|
||||
],
|
||||
"secondary": []
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"f871b8c4d63549319221e294e4f88074": {
|
||||
"active_preset": "home",
|
||||
"active_preset": "vacation",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "auto",
|
||||
@@ -181,12 +231,12 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Bathroom",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"select_schedule": "Badkamer",
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 0.0,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 23.9
|
||||
"temperature": 25.8
|
||||
},
|
||||
"thermostat": {
|
||||
"lower_bound": 0.0,
|
||||
|
||||
@@ -30,6 +30,32 @@
|
||||
"dhw_cm_switch": false
|
||||
}
|
||||
},
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Emma Pro",
|
||||
"model_id": "170-01",
|
||||
"name": "Emma",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.0,
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.5
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.0,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "60EFABFFFE89CBA0"
|
||||
},
|
||||
"1772a4ea304041adb83f357b751341ff": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
@@ -43,11 +69,11 @@
|
||||
"model_id": "106-03",
|
||||
"name": "Tom Badkamer",
|
||||
"sensors": {
|
||||
"battery": 99,
|
||||
"setpoint": 18.0,
|
||||
"battery": 60,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 18.6,
|
||||
"temperature_difference": -0.2,
|
||||
"valve_position": 100
|
||||
"temperature_difference": -0.4,
|
||||
"valve_position": 100.0
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
@@ -56,10 +82,9 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C8FF5EE"
|
||||
"zigbee_mac_address": "000D6F000C8FCBA0"
|
||||
},
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
|
||||
"available": true,
|
||||
"dev_class": "thermostat",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "ThermoTouch",
|
||||
@@ -76,28 +101,49 @@
|
||||
"plugwise_notification": false
|
||||
},
|
||||
"dev_class": "gateway",
|
||||
"firmware": "3.7.8",
|
||||
"firmware": "3.9.0",
|
||||
"gateway_modes": ["away", "full", "vacation"],
|
||||
"hardware": "AME Smile 2.0 board",
|
||||
"location": "bc93488efab249e5bc54fd7e175a6f91",
|
||||
"mac_address": "012345679891",
|
||||
"mac_address": "D40FB201CBA0",
|
||||
"model": "Gateway",
|
||||
"model_id": "smile_open_therm",
|
||||
"name": "Adam",
|
||||
"notifications": {},
|
||||
"regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"],
|
||||
"regulation_modes": ["bleeding_cold", "heating", "off", "bleeding_hot"],
|
||||
"select_gateway_mode": "full",
|
||||
"select_regulation_mode": "heating",
|
||||
"sensors": {
|
||||
"outdoor_temperature": -1.25
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000D5A168D"
|
||||
"zigbee_mac_address": "000D6F000D5ACBA0"
|
||||
},
|
||||
"da575e9e09b947e281fb6e3ebce3b174": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermometer",
|
||||
"firmware": "2020-09-01T02:00:00+02:00",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Jip",
|
||||
"model_id": "168-01",
|
||||
"name": "Jip",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.8,
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.3
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "70AC08FFFEE1CBA0"
|
||||
},
|
||||
"e2f4322d57924fa090fbbc48b3a140dc": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": true
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"firmware": "2016-10-10T02:00:00+02:00",
|
||||
@@ -107,7 +153,7 @@
|
||||
"model_id": "158-01",
|
||||
"name": "Lisa Badkamer",
|
||||
"sensors": {
|
||||
"battery": 14,
|
||||
"battery": 71,
|
||||
"setpoint": 15.0,
|
||||
"temperature": 17.9
|
||||
},
|
||||
@@ -118,7 +164,7 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C869B61"
|
||||
"zigbee_mac_address": "000D6F000C86CBA0"
|
||||
},
|
||||
"e8ef2a01ed3b4139a53bf749204fe6b4": {
|
||||
"dev_class": "switching",
|
||||
@@ -137,9 +183,9 @@
|
||||
"active_preset": "home",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "heat",
|
||||
@@ -147,10 +193,10 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Living room",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 149.9,
|
||||
"electricity_consumed": 60.8,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 19.1
|
||||
},
|
||||
@@ -161,18 +207,22 @@
|
||||
"upper_bound": 35.0
|
||||
},
|
||||
"thermostats": {
|
||||
"primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"],
|
||||
"primary": [
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8",
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6",
|
||||
"da575e9e09b947e281fb6e3ebce3b174"
|
||||
],
|
||||
"secondary": []
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"f871b8c4d63549319221e294e4f88074": {
|
||||
"active_preset": "home",
|
||||
"active_preset": "vacation",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "auto",
|
||||
@@ -180,8 +230,8 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Bathroom",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"select_schedule": "Badkamer",
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 0.0,
|
||||
"electricity_produced": 0.0,
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 0.0,
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
@@ -63,11 +63,11 @@
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 0.0,
|
||||
'preset_mode': 'home',
|
||||
'preset_mode': 'vacation',
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
@@ -97,9 +97,9 @@
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 1.0,
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
@@ -149,9 +149,9 @@
|
||||
'min_temp': 1.0,
|
||||
'preset_mode': 'home',
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'bleeding_hot',
|
||||
'bleeding_cold',
|
||||
'off',
|
||||
'heating',
|
||||
'off',
|
||||
'bleeding_hot',
|
||||
'cooling',
|
||||
]),
|
||||
}),
|
||||
@@ -106,10 +106,10 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Adam Regulation mode',
|
||||
'options': list([
|
||||
'bleeding_hot',
|
||||
'bleeding_cold',
|
||||
'off',
|
||||
'heating',
|
||||
'off',
|
||||
'bleeding_hot',
|
||||
'cooling',
|
||||
]),
|
||||
}),
|
||||
@@ -129,9 +129,9 @@
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
@@ -170,9 +170,9 @@
|
||||
'friendly_name': 'Bathroom Thermostat schedule',
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
@@ -181,7 +181,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'Badkamer',
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-entry]
|
||||
@@ -192,9 +192,9 @@
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
@@ -233,9 +233,9 @@
|
||||
'friendly_name': 'Living room Thermostat schedule',
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,7 +257,7 @@ async def test_update_device(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 38
|
||||
== 49
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
@@ -265,7 +265,7 @@ async def test_update_device(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 8
|
||||
== 10
|
||||
)
|
||||
|
||||
# Add a 2nd Tom/Floor
|
||||
@@ -289,7 +289,7 @@ async def test_update_device(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 45
|
||||
== 56
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
@@ -297,7 +297,7 @@ async def test_update_device(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 9
|
||||
== 11
|
||||
)
|
||||
item_list: list[str] = []
|
||||
for device_entry in list(device_registry.devices.values()):
|
||||
@@ -320,7 +320,7 @@ async def test_update_device(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 38
|
||||
== 49
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
@@ -328,7 +328,7 @@ async def test_update_device(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 8
|
||||
== 10
|
||||
)
|
||||
item_list: list[str] = []
|
||||
for device_entry in list(device_registry.devices.values()):
|
||||
|
||||
@@ -13,11 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
|
||||
@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)])
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_adam_sensor_snapshot(
|
||||
hass: HomeAssistant,
|
||||
mock_smile_adam: MagicMock,
|
||||
mock_smile_adam_heat_cool: MagicMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
setup_platform: MockConfigEntry,
|
||||
|
||||
161
tests/components/satel_integra/snapshots/test_binary_sensor.ambr
Normal file
161
tests/components/satel_integra/snapshots/test_binary_sensor.ambr
Normal file
@@ -0,0 +1,161 @@
|
||||
# serializer version: 1
|
||||
# name: test_binary_sensors[binary_sensor.output-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.output',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.SAFETY: 'safety'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'satel_integra',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '1234567890_outputs_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[binary_sensor.output-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'safety',
|
||||
'friendly_name': 'Output',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.output',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[binary_sensor.zone-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.zone',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.MOTION: 'motion'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'satel_integra',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '1234567890_zones_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[binary_sensor.zone-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'motion',
|
||||
'friendly_name': 'Zone',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.zone',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[device-output]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'satel_integra',
|
||||
'1234567890_outputs_1',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': None,
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'Output',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[device-zone]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'satel_integra',
|
||||
'1234567890_zones_1',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': None,
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'Zone',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
102
tests/components/satel_integra/test_binary_sensor.py
Normal file
102
tests/components/satel_integra/test_binary_sensor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Test Satel Integra Binary Sensor."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON
|
||||
from homeassistant.components.satel_integra.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceRegistry
|
||||
from homeassistant.helpers.entity_registry import EntityRegistry
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def binary_sensor_only() -> AsyncGenerator[None]:
|
||||
"""Enable only the binary sensor platform."""
|
||||
with patch(
|
||||
"homeassistant.components.satel_integra.PLATFORMS",
|
||||
[Platform.BINARY_SENSOR],
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_satel")
|
||||
async def test_binary_sensors(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry_with_subentries: MockConfigEntry,
|
||||
entity_registry: EntityRegistry,
|
||||
device_registry: DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test binary sensors correctly being set up."""
|
||||
await setup_integration(hass, mock_config_entry_with_subentries)
|
||||
|
||||
assert mock_config_entry_with_subentries.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry_with_subentries.entry_id
|
||||
)
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "1234567890_zones_1")}
|
||||
)
|
||||
|
||||
assert device_entry == snapshot(name="device-zone")
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "1234567890_outputs_1")}
|
||||
)
|
||||
assert device_entry == snapshot(name="device-output")
|
||||
|
||||
|
||||
async def test_binary_sensor_initial_state_on(
|
||||
hass: HomeAssistant,
|
||||
mock_satel: AsyncMock,
|
||||
mock_config_entry_with_subentries: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test binary sensors have a correct initial state ON after initialization."""
|
||||
mock_satel.violated_zones = [1]
|
||||
mock_satel.violated_outputs = [1]
|
||||
|
||||
await setup_integration(hass, mock_config_entry_with_subentries)
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_ON
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_ON
|
||||
|
||||
|
||||
async def test_binary_sensor_callback(
|
||||
hass: HomeAssistant,
|
||||
mock_satel: AsyncMock,
|
||||
mock_config_entry_with_subentries: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test binary sensors correctly change state after a callback from the panel."""
|
||||
await setup_integration(hass, mock_config_entry_with_subentries)
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_OFF
|
||||
|
||||
monitor_status_call = mock_satel.monitor_status.call_args_list[0][0]
|
||||
output_update_method = monitor_status_call[2]
|
||||
zone_update_method = monitor_status_call[1]
|
||||
|
||||
# Should do nothing, only react to it's own number
|
||||
output_update_method({"outputs": {2: 1}})
|
||||
zone_update_method({"zones": {2: 1}})
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_OFF
|
||||
|
||||
output_update_method({"outputs": {1: 1}})
|
||||
zone_update_method({"zones": {1: 1}})
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_ON
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_ON
|
||||
@@ -1,4 +1,196 @@
|
||||
# serializer version: 1
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_factory_reset-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.kattenbak_factory_reset',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Factory reset',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'factory_reset',
|
||||
'unique_id': 'tuya.yohkwjjdjlzludd3psmfactory_reset',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_factory_reset-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Kattenbak Factory reset',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.kattenbak_factory_reset',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_manual_clean-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.kattenbak_manual_clean',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Manual clean',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'manual_clean',
|
||||
'unique_id': 'tuya.yohkwjjdjlzludd3psmmanual_clean',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_manual_clean-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Kattenbak Manual clean',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.kattenbak_manual_clean',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_factory_reset-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.poopy_nano_2_factory_reset',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Factory reset',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'factory_reset',
|
||||
'unique_id': 'tuya.nyriu7sjgj9oruzmpsmfactory_reset',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_factory_reset-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Poopy Nano 2 Factory reset',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.poopy_nano_2_factory_reset',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_manual_clean-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.poopy_nano_2_manual_clean',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Manual clean',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'manual_clean',
|
||||
'unique_id': 'tuya.nyriu7sjgj9oruzmpsmmanual_clean',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_manual_clean-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Poopy Nano 2 Manual clean',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.poopy_nano_2_manual_clean',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.v20_reset_duster_cloth-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
'event_type': None,
|
||||
'event_type': 'click',
|
||||
'event_types': list([
|
||||
'click',
|
||||
'press',
|
||||
@@ -55,7 +55,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
'state': '2023-11-01T12:14:15.000+00:00',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_2-entry]
|
||||
@@ -102,7 +102,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
'event_type': None,
|
||||
'event_type': 'click',
|
||||
'event_types': list([
|
||||
'click',
|
||||
'press',
|
||||
@@ -114,7 +114,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
'state': '2023-11-01T12:14:15.000+00:00',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[event.bouton_tempo_exterieur_button_1-entry]
|
||||
@@ -162,7 +162,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
'event_type': None,
|
||||
'event_type': 'press',
|
||||
'event_types': list([
|
||||
'click',
|
||||
'double_click',
|
||||
@@ -175,6 +175,6 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
'state': '2023-11-01T12:14:15.000+00:00',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -1827,6 +1827,63 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.kattenbak_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'light.kattenbak_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Light',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light',
|
||||
'unique_id': 'tuya.yohkwjjdjlzludd3psmlight',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.kattenbak_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Kattenbak Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.kattenbak_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.landing-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
@@ -17,6 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON])
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_platform_setup_and_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
@@ -29,3 +32,33 @@ async def test_platform_setup_and_discovery(
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON])
|
||||
@pytest.mark.parametrize(
|
||||
"mock_device_code",
|
||||
["sd_lr33znaodtyarrrz"],
|
||||
)
|
||||
async def test_button_press(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device: CustomerDevice,
|
||||
) -> None:
|
||||
"""Test pressing a button."""
|
||||
entity_id = "button.v20_reset_duster_cloth"
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None, f"{entity_id} does not exist"
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_manager.send_commands.assert_called_once_with(
|
||||
mock_device.id, [{"code": "reset_duster_cloth", "value": True}]
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -11,21 +12,29 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import initialize_entry
|
||||
from . import MockDeviceListener, initialize_entry
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-11-01 13:14:15+01:00")
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT])
|
||||
async def test_platform_setup_and_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_devices: list[CustomerDevice],
|
||||
mock_listener: MockDeviceListener,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test platform setup and discovery."""
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices)
|
||||
|
||||
for mock_device in mock_devices:
|
||||
# Simulate an initial device update to generate events
|
||||
await mock_listener.async_send_device_update(
|
||||
hass, mock_device, mock_device.status
|
||||
)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
@@ -7,7 +7,9 @@ import pytest
|
||||
|
||||
from homeassistant.components.velux import DOMAIN
|
||||
from homeassistant.components.velux.binary_sensor import Window
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
|
||||
from homeassistant.components.velux.light import LighteningDevice
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -79,10 +81,20 @@ def mock_window() -> AsyncMock:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pyvlx(mock_window: MagicMock) -> Generator[MagicMock]:
|
||||
def mock_light() -> AsyncMock:
|
||||
"""Create a mock Velux light."""
|
||||
light = AsyncMock(spec=LighteningDevice, autospec=True)
|
||||
light.name = "Test Light"
|
||||
light.serial_number = "0815"
|
||||
light.intensity = MagicMock()
|
||||
return light
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pyvlx(mock_window: MagicMock, mock_light: MagicMock) -> Generator[MagicMock]:
|
||||
"""Create the library mock and patch PyVLX."""
|
||||
pyvlx = MagicMock()
|
||||
pyvlx.nodes = [mock_window]
|
||||
pyvlx.nodes = [mock_window, mock_light]
|
||||
pyvlx.load_scenes = AsyncMock()
|
||||
pyvlx.load_nodes = AsyncMock()
|
||||
pyvlx.disconnect = AsyncMock()
|
||||
@@ -101,3 +113,18 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_PASSWORD: "testpw",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_pyvlx: MagicMock,
|
||||
platform: Platform,
|
||||
) -> None:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.velux.PLATFORMS", [platform]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
43
tests/components/velux/test_light.py
Normal file
43
tests/components/velux/test_light.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Test Velux light entities."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform() -> Platform:
|
||||
"""Fixture to specify platform to test."""
|
||||
return Platform.LIGHT
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_integration")
|
||||
async def test_light_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_light: AsyncMock,
|
||||
) -> None:
|
||||
"""Test light entity setup and device association."""
|
||||
|
||||
test_entity_id = f"light.{mock_light.name.lower().replace(' ', '_')}"
|
||||
|
||||
# Check that the entity exists and its name matches the node name (the light is the main feature).
|
||||
state = hass.states.get(test_entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("friendly_name") == mock_light.name
|
||||
|
||||
# Get entity + device entry
|
||||
entity_entry = entity_registry.async_get(test_entity_id)
|
||||
assert entity_entry is not None
|
||||
assert entity_entry.device_id is not None
|
||||
device_entry = device_registry.async_get(entity_entry.device_id)
|
||||
assert device_entry is not None
|
||||
|
||||
# Verify device has correct identifiers + name
|
||||
assert ("velux", mock_light.serial_number) in device_entry.identifiers
|
||||
assert device_entry.name == mock_light.name
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
@@ -13,6 +13,9 @@ from homeassistant.components.volvo.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import configure_mock
|
||||
from .const import MOCK_ACCESS_TOKEN, SERVER_TOKEN_RESPONSE
|
||||
@@ -132,3 +135,20 @@ async def test_vehicle_auth_failure(
|
||||
configure_mock(mock_method, return_value=None, side_effect=VolvoAuthException())
|
||||
assert not await setup_integration()
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.volvo.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
@@ -450,10 +450,10 @@ async def test_caching(hass: HomeAssistant) -> None:
|
||||
side_effect=translation.build_resources,
|
||||
) as mock_build_resources:
|
||||
load1 = await translation.async_get_translations(hass, "en", "entity_component")
|
||||
assert len(mock_build_resources.mock_calls) == 7
|
||||
assert len(mock_build_resources.mock_calls) == 9
|
||||
|
||||
load2 = await translation.async_get_translations(hass, "en", "entity_component")
|
||||
assert len(mock_build_resources.mock_calls) == 7
|
||||
assert len(mock_build_resources.mock_calls) == 9
|
||||
|
||||
assert load1 == load2
|
||||
|
||||
|
||||
Reference in New Issue
Block a user