Compare commits

..

63 Commits

Author SHA1 Message Date
abmantis
d72c2d9f44 Address review comments 2025-11-10 18:32:05 +00:00
abmantis
7b1eb5f6fb Split condition 2025-11-10 15:46:24 +00:00
abmantis
30713d8eb6 Remove "one" behavior; make "all" behavior true with empty list 2025-11-10 15:33:20 +00:00
abmantis
8b28fe3593 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-11-10 15:27:29 +00:00
abmantis
07ef61dd8d Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-10 14:19:18 +00:00
Tom Matheussen
037e0e93d3 Cleanup binary sensor platform for Satel Integra (#155915)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 15:13:49 +01:00
epenet
db8b5865b3 Improve Tuya event tests (#156259) 2025-11-10 15:03:23 +01:00
epenet
bd2ccc6672 Add tests for tuya button (#156252) 2025-11-10 14:54:51 +01:00
Joost Lekkerkerker
bb63d40cdf Bump pySmartThings to 3.3.2 (#156250) 2025-11-10 14:53:29 +01:00
Ludovic BOUÉ
65285b8885 Fix Matter ValveFault attribute handling (#156258)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-10 14:45:13 +01:00
Denis Shulyaka
326b8f2b4f Add AI task for Anthropic (#156221) 2025-11-10 14:01:28 +01:00
Heindrich Paul
9f3df52fcc Added light support to cat litter boxes (#156051) 2025-11-10 13:57:54 +01:00
wollew
875838c277 adjust naming of velux light entities according to guidelines (#155850) 2025-11-10 13:55:17 +01:00
epenet
adaafd1fda Use dpcode_wrapper in tuya binary sensor platform (#156247)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 13:54:09 +01:00
Heindrich Paul
50c5efddaa Add buttons for cat litter box devices (#156050) 2025-11-10 13:50:40 +01:00
epenet
c4be054161 Adjust Tuya DPCodeBooleanWrapper inheritance (#156255) 2025-11-10 13:39:09 +01:00
Bouwe Westerdijk
61186356f3 Refresh test-fixtures for Plugwise (#156253) 2025-11-10 13:35:24 +01:00
Will Moss
9d60a19440 Improved error handling for oauth2 configuration in volvo integration (#156215) 2025-11-10 13:17:48 +01:00
epenet
108c212855 Use dpcode_wrapper in tuya button platform (#156237) 2025-11-10 12:58:42 +01:00
Erik Montnemery
ae8db81c4e Use pytest.mark.freeze_time in ambient_network tests (#156241) 2025-11-10 12:50:43 +01:00
dotvav
51c970d1d0 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-10 12:49:40 +01:00
abmantis
1bf6771a54 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-06 19:57:40 +00:00
abmantis
e7a7cb829e Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-04 12:28:39 +00:00
abmantis
89c774fc76 Properly handle unavailable states 2025-10-16 15:04:53 +01:00
abmantis
53b4893ee1 Add typing 2025-10-15 23:30:27 +01:00
abmantis
3dee03c46c Update test comment + missing check 2025-10-15 23:28:14 +01:00
abmantis
86a412be21 Fix translation test 2025-10-15 18:34:54 +01:00
abmantis
f23dae4751 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-10-15 17:04:01 +01:00
abmantis
6f6b2f1ad3 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-15 17:03:28 +01:00
abmantis
1cc4890f75 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-15 17:03:18 +01:00
abmantis
7541c291d5 Add missing import 2025-10-14 17:43:18 +01:00
abmantis
c08bbbdf4c Move init after class methods 2025-10-14 16:11:41 +01:00
Abílio Costa
bc34ce0dba Update tests/components/light/test_condition.py
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-10-14 16:10:52 +01:00
Abílio Costa
6ff4d7a6d1 Update homeassistant/components/light/condition.py
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-10-14 16:10:42 +01:00
abmantis
86d84b1342 Add a non-existing entity_id to increase coverage 2025-10-10 23:55:28 +01:00
abmantis
2303d8089d Remove duplicated code from tests 2025-10-10 23:04:10 +01:00
abmantis
381368644f Fix translation keys 2025-10-10 22:07:17 +01:00
abmantis
b9ce6de97c Move asserts to type_checking block 2025-10-10 22:01:21 +01:00
abmantis
ca12d7b043 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-10-10 12:59:16 +01:00
Bram Kragten
d3dd9b26c9 Fixes for triggers.yaml descriptions (#153841)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-10-09 18:00:56 +01:00
Abílio Costa
a64d61df05 Fix light trigger with new Trigger class changes (#154087) 2025-10-09 18:14:55 +02:00
abmantis
e7c6c5311d Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-09 15:55:39 +01:00
abmantis
b5e9910856 Fix colliding strings 2025-10-02 18:56:23 +01:00
abmantis
d3d5d7acac Update condition to new format 2025-09-29 17:47:08 +01:00
abmantis
9e42dc3a12 Revert builtin conditions changes 2025-09-29 16:58:50 +01:00
abmantis
8d32577c72 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-09-29 16:57:34 +01:00
abmantis
72a524c868 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-09-29 16:56:23 +01:00
abmantis
3f0735be8e Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-09-29 16:54:26 +01:00
abmantis
b437113f31 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-09-29 11:18:39 +01:00
Abílio Costa
e0e263d3b5 Add state trigger to light component (#148416)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-09-18 19:53:26 +01:00
abmantis
0dfc375c23 Move CONF_CONDITION to base condition schema
By the time the condition schema is validated, the correct condition
platform is already ensured.
This removes the need to specify it in individual condition schemas.
2025-09-16 17:31:16 +01:00
abmantis
42f4513ce2 Move fields to options 2025-09-16 16:35:19 +01:00
abmantis
1fb657c439 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-09-16 15:54:07 +01:00
Abílio Costa
53c6c0bac7 Merge branch 'dev_target_triggers_conditions' into light_target_condition 2025-08-25 18:57:20 +01:00
abmantis
c27c4488ef Use TARGET_SELECTION_SCHEMA 2025-08-07 19:18:08 +01:00
abmantis
a8fa656395 Minor changes 2025-08-07 19:13:54 +01:00
abmantis
b2bd1a4aa0 Fix target key; add other platform entities on tests 2025-08-07 19:11:50 +01:00
abmantis
cef2ccae65 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-08-07 17:43:49 +01:00
abmantis
89f5814310 Change from state to select selector 2025-08-04 17:45:07 +01:00
abmantis
f378c96c9d Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into light_target_condition 2025-08-04 17:35:01 +01:00
abmantis
f4f71777d2 Fix translations test 2025-08-01 19:26:01 +01:00
abmantis
37807fbf5a Make hassfest happy 2025-08-01 18:21:42 +01:00
abmantis
ffc23a5dda Add light state condition 2025-08-01 18:21:23 +01:00
58 changed files with 4153 additions and 4583 deletions

View File

@@ -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]

View 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,
)

View File

@@ -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)

View File

@@ -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"

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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]),
}
)

View 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

View 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

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View 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

View 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

View File

@@ -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,

View File

@@ -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"]
}

View File

@@ -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()

View File

@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.1"]
"requirements": ["pysmartthings==3.3.2"]
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -77,6 +77,12 @@
}
},
"button": {
"factory_reset": {
"name": "Factory reset"
},
"manual_clean": {
"name": "Manual clean"
},
"reset_duster_cloth": {
"name": "Reset duster cloth"
},

View File

@@ -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,

View File

@@ -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)

View File

@@ -35,6 +35,7 @@ class VeluxLight(VeluxEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_name = None
node: LighteningDevice

View File

@@ -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)

View File

@@ -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}"
},

View File

@@ -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
View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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"),
]

View File

@@ -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

View 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",
},
},
]

View File

@@ -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

View 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)

View 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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,

View File

@@ -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',
]),

View File

@@ -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

View File

@@ -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()):

View File

@@ -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,

View 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,
})
# ---

View 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

View File

@@ -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({

View File

@@ -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',
})
# ---

View File

@@ -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({

View File

@@ -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}]
)

View File

@@ -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)

View File

@@ -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()

View 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

View File

@@ -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

View File

@@ -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