mirror of
https://github.com/home-assistant/core.git
synced 2025-11-10 19:40:11 +00:00
Compare commits
18 Commits
copilot/ad
...
template-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c6e59acd2 | ||
|
|
63aa41c766 | ||
|
|
037e0e93d3 | ||
|
|
db8b5865b3 | ||
|
|
bd2ccc6672 | ||
|
|
bb63d40cdf | ||
|
|
65285b8885 | ||
|
|
326b8f2b4f | ||
|
|
9f3df52fcc | ||
|
|
875838c277 | ||
|
|
adaafd1fda | ||
|
|
50c5efddaa | ||
|
|
c4be054161 | ||
|
|
61186356f3 | ||
|
|
9d60a19440 | ||
|
|
108c212855 | ||
|
|
ae8db81c4e | ||
|
|
51c970d1d0 |
@@ -25,7 +25,7 @@ from .const import (
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
)
|
||||
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||
|
||||
80
homeassistant/components/anthropic/ai_task.py
Normal file
80
homeassistant/components/anthropic/ai_task.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""AI Task integration for Anthropic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from json import JSONDecodeError
|
||||
import logging
|
||||
|
||||
from homeassistant.components import ai_task, conversation
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up AI Task entities."""
|
||||
for subentry in config_entry.subentries.values():
|
||||
if subentry.subentry_type != "ai_task_data":
|
||||
continue
|
||||
|
||||
async_add_entities(
|
||||
[AnthropicTaskEntity(config_entry, subentry)],
|
||||
config_subentry_id=subentry.subentry_id,
|
||||
)
|
||||
|
||||
|
||||
class AnthropicTaskEntity(
|
||||
ai_task.AITaskEntity,
|
||||
AnthropicBaseLLMEntity,
|
||||
):
|
||||
"""Anthropic AI Task entity."""
|
||||
|
||||
_attr_supported_features = (
|
||||
ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
|
||||
)
|
||||
|
||||
async def _async_generate_data(
|
||||
self,
|
||||
task: ai_task.GenDataTask,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> ai_task.GenDataTaskResult:
|
||||
"""Handle a generate data task."""
|
||||
await self._async_handle_chat_log(chat_log, task.name, task.structure)
|
||||
|
||||
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||
raise HomeAssistantError(
|
||||
"Last content in chat log is not an AssistantContent"
|
||||
)
|
||||
|
||||
text = chat_log.content[-1].content or ""
|
||||
|
||||
if not task.structure:
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
data=text,
|
||||
)
|
||||
try:
|
||||
data = json_loads(text)
|
||||
except JSONDecodeError as err:
|
||||
_LOGGER.error(
|
||||
"Failed to parse JSON response: %s. Response: %s",
|
||||
err,
|
||||
text,
|
||||
)
|
||||
raise HomeAssistantError("Error with Claude structured response") from err
|
||||
|
||||
return ai_task.GenDataTaskResult(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
data=data,
|
||||
)
|
||||
@@ -53,6 +53,7 @@ from .const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
NON_THINKING_MODELS,
|
||||
@@ -74,12 +75,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
RECOMMENDED_OPTIONS = {
|
||||
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||
}
|
||||
|
||||
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
@@ -102,7 +107,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors = {}
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
@@ -130,10 +135,16 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_OPTIONS,
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
@@ -147,7 +158,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
cls, config_entry: ConfigEntry
|
||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||
"""Return subentries supported by this integration."""
|
||||
return {"conversation": ConversationSubentryFlowHandler}
|
||||
return {
|
||||
"conversation": ConversationSubentryFlowHandler,
|
||||
"ai_task_data": ConversationSubentryFlowHandler,
|
||||
}
|
||||
|
||||
|
||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
@@ -164,7 +178,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> SubentryFlowResult:
|
||||
"""Add a subentry."""
|
||||
self.options = RECOMMENDED_OPTIONS.copy()
|
||||
if self._subentry_type == "ai_task_data":
|
||||
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||
else:
|
||||
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
return await self.async_step_init()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
@@ -198,23 +215,29 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if self._is_new:
|
||||
step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = (
|
||||
str
|
||||
if self._subentry_type == "ai_task_data":
|
||||
default_name = DEFAULT_AI_TASK_NAME
|
||||
else:
|
||||
default_name = DEFAULT_CONVERSATION_NAME
|
||||
step_schema[vol.Required(CONF_NAME, default=default_name)] = str
|
||||
|
||||
if self._subentry_type == "conversation":
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||
vol.Optional(
|
||||
CONF_LLM_HASS_API,
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||
),
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
|
||||
): bool,
|
||||
}
|
||||
)
|
||||
step_schema[
|
||||
vol.Required(
|
||||
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
|
||||
)
|
||||
] = bool
|
||||
|
||||
if user_input is not None:
|
||||
if not user_input.get(CONF_LLM_HASS_API):
|
||||
@@ -298,10 +321,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
if not model.startswith(tuple(NON_THINKING_MODELS)):
|
||||
step_schema[
|
||||
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
|
||||
] = NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
|
||||
)
|
||||
] = vol.All(
|
||||
NumberSelector(
|
||||
NumberSelectorConfig(
|
||||
min=0,
|
||||
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
)
|
||||
),
|
||||
vol.Coerce(int),
|
||||
)
|
||||
else:
|
||||
self.options.pop(CONF_THINKING_BUDGET, None)
|
||||
|
||||
@@ -6,6 +6,7 @@ DOMAIN = "anthropic"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||
DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic.types import (
|
||||
Base64ImageSourceParam,
|
||||
Base64PDFSourceParam,
|
||||
CitationsDelta,
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
ImageBlockParam,
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
@@ -37,6 +44,9 @@ from anthropic.types import (
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolChoiceAnyParam,
|
||||
ToolChoiceAutoParam,
|
||||
ToolChoiceToolParam,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUnionParam,
|
||||
@@ -50,13 +60,16 @@ from anthropic.types import (
|
||||
WebSearchToolResultError,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.util import slugify
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
@@ -321,6 +334,7 @@ def _convert_content(
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
stream: AsyncStream[MessageStreamEvent],
|
||||
output_tool: str | None = None,
|
||||
) -> AsyncGenerator[
|
||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||
]:
|
||||
@@ -381,6 +395,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
if response.content_block.name == output_tool:
|
||||
if first_block or content_details.has_content():
|
||||
if content_details.has_citations():
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
content_details = ContentDetails()
|
||||
content_details.add_citation_detail()
|
||||
yield {"role": "assistant"}
|
||||
has_native = False
|
||||
first_block = False
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||
first_block
|
||||
@@ -471,7 +495,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
if (
|
||||
current_tool_block is not None
|
||||
and current_tool_block["name"] == output_tool
|
||||
):
|
||||
content_details.citation_details[-1].length += len(
|
||||
response.delta.partial_json
|
||||
)
|
||||
yield {"content": response.delta.partial_json}
|
||||
else:
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
content_details.citation_details[-1].length += len(response.delta.text)
|
||||
yield {"content": response.delta.text}
|
||||
@@ -490,6 +523,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
content_details.add_citation(response.delta.citation)
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_tool_block is not None:
|
||||
if current_tool_block["name"] == output_tool:
|
||||
current_tool_block = None
|
||||
continue
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_tool_block["input"] = tool_args
|
||||
yield {
|
||||
@@ -557,6 +593,8 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
structure_name: str | None = None,
|
||||
structure: vol.Schema | None = None,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
@@ -613,6 +651,74 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
}
|
||||
tools.append(web_search)
|
||||
|
||||
# Handle attachments by adding them to the last user message
|
||||
last_content = chat_log.content[-1]
|
||||
if last_content.role == "user" and last_content.attachments:
|
||||
last_message = messages[-1]
|
||||
if last_message["role"] != "user":
|
||||
raise HomeAssistantError(
|
||||
"Last message must be a user message to add attachments"
|
||||
)
|
||||
if isinstance(last_message["content"], str):
|
||||
last_message["content"] = [
|
||||
TextBlockParam(type="text", text=last_message["content"])
|
||||
]
|
||||
last_message["content"].extend( # type: ignore[union-attr]
|
||||
await async_prepare_files_for_prompt(
|
||||
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
|
||||
)
|
||||
)
|
||||
|
||||
if structure and structure_name:
|
||||
structure_name = slugify(structure_name)
|
||||
if model_args["thinking"]["type"] == "disabled":
|
||||
if not tools:
|
||||
# Simplest case: no tools and no extended thinking
|
||||
# Add a tool and force its use
|
||||
model_args["tool_choice"] = ToolChoiceToolParam(
|
||||
type="tool",
|
||||
name=structure_name,
|
||||
)
|
||||
else:
|
||||
# Second case: tools present but no extended thinking
|
||||
# Allow the model to use any tool but not text response
|
||||
# The model should know to use the right tool by its description
|
||||
model_args["tool_choice"] = ToolChoiceAnyParam(
|
||||
type="any",
|
||||
)
|
||||
else:
|
||||
# Extended thinking is enabled. With extended thinking, we cannot
|
||||
# force tool use or disable text responses, so we add a hint to the
|
||||
# system prompt instead. With extended thinking, the model should be
|
||||
# smart enough to use the tool.
|
||||
model_args["tool_choice"] = ToolChoiceAutoParam(
|
||||
type="auto",
|
||||
)
|
||||
|
||||
if isinstance(model_args["system"], str):
|
||||
model_args["system"] = [
|
||||
TextBlockParam(type="text", text=model_args["system"])
|
||||
]
|
||||
model_args["system"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(
|
||||
type="text",
|
||||
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
|
||||
)
|
||||
)
|
||||
|
||||
tools.append(
|
||||
ToolParam(
|
||||
name=structure_name,
|
||||
description="Use this tool to reply to the user",
|
||||
input_schema=convert(
|
||||
structure,
|
||||
custom_serializer=chat_log.llm_api.custom_serializer
|
||||
if chat_log.llm_api
|
||||
else llm.selector_serializer,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
if tools:
|
||||
model_args["tools"] = tools
|
||||
|
||||
@@ -629,7 +735,11 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream),
|
||||
_transform_stream(
|
||||
chat_log,
|
||||
stream,
|
||||
output_tool=structure_name if structure else None,
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -641,3 +751,59 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
|
||||
async def async_prepare_files_for_prompt(
|
||||
hass: HomeAssistant, files: list[tuple[Path, str | None]]
|
||||
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
|
||||
"""Append files to a prompt.
|
||||
|
||||
Caller needs to ensure that the files are allowed.
|
||||
"""
|
||||
|
||||
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
|
||||
content: list[ImageBlockParam | DocumentBlockParam] = []
|
||||
|
||||
for file_path, mime_type in files:
|
||||
if not file_path.exists():
|
||||
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||
|
||||
if mime_type is None:
|
||||
mime_type = guess_file_type(file_path)[0]
|
||||
|
||||
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||
raise HomeAssistantError(
|
||||
"Only images and PDF are supported by the Anthropic API,"
|
||||
f"`{file_path}` is not an image file or PDF"
|
||||
)
|
||||
if mime_type == "image/jpg":
|
||||
mime_type = "image/jpeg"
|
||||
|
||||
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
|
||||
|
||||
if mime_type.startswith("image/"):
|
||||
content.append(
|
||||
ImageBlockParam(
|
||||
type="image",
|
||||
source=Base64ImageSourceParam(
|
||||
type="base64",
|
||||
media_type=mime_type, # type: ignore[typeddict-item]
|
||||
data=base64_file,
|
||||
),
|
||||
)
|
||||
)
|
||||
elif mime_type.startswith("application/pdf"):
|
||||
content.append(
|
||||
DocumentBlockParam(
|
||||
type="document",
|
||||
source=Base64PDFSourceParam(
|
||||
type="base64",
|
||||
media_type=mime_type, # type: ignore[typeddict-item]
|
||||
data=base64_file,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
return await hass.async_add_executor_job(append_files_to_content)
|
||||
|
||||
@@ -18,6 +18,49 @@
|
||||
}
|
||||
},
|
||||
"config_subentries": {
|
||||
"ai_task_data": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "[%key:component::anthropic::config_subentries::conversation::abort::entry_not_loaded%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"entry_type": "AI task",
|
||||
"initiate_flow": {
|
||||
"reconfigure": "Reconfigure AI task",
|
||||
"user": "Add AI task"
|
||||
},
|
||||
"step": {
|
||||
"advanced": {
|
||||
"data": {
|
||||
"chat_model": "[%key:common::generic::model%]",
|
||||
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
|
||||
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
|
||||
},
|
||||
"init": {
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"recommended": "[%key:component::anthropic::config_subentries::conversation::step::init::data::recommended%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::init::title%]"
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
|
||||
},
|
||||
"data_description": {
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
|
||||
},
|
||||
"title": "[%key:component::anthropic::config_subentries::conversation::step::model::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"abort": {
|
||||
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
|
||||
@@ -46,7 +89,8 @@
|
||||
},
|
||||
"data_description": {
|
||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||
}
|
||||
},
|
||||
"title": "Basic settings"
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiomealie==1.0.1"]
|
||||
"requirements": ["aiomealie==1.1.0"]
|
||||
}
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["pypalazzetti==0.1.19"]
|
||||
"requirements": ["pypalazzetti==0.1.20"]
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from satel_integra.satel_integra import AsyncSatel
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
@@ -17,6 +20,7 @@ from .const import (
|
||||
CONF_ZONE_NUMBER,
|
||||
CONF_ZONE_TYPE,
|
||||
CONF_ZONES,
|
||||
DOMAIN,
|
||||
SIGNAL_OUTPUTS_UPDATED,
|
||||
SIGNAL_ZONES_UPDATED,
|
||||
SUBENTRY_TYPE_OUTPUT,
|
||||
@@ -40,9 +44,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry in zone_subentries:
|
||||
zone_num = subentry.data[CONF_ZONE_NUMBER]
|
||||
zone_type = subentry.data[CONF_ZONE_TYPE]
|
||||
zone_name = subentry.data[CONF_NAME]
|
||||
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
|
||||
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||
zone_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
@@ -65,9 +69,9 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
for subentry in output_subentries:
|
||||
output_num = subentry.data[CONF_OUTPUT_NUMBER]
|
||||
ouput_type = subentry.data[CONF_ZONE_TYPE]
|
||||
output_name = subentry.data[CONF_NAME]
|
||||
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
|
||||
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||
output_name: str = subentry.data[CONF_NAME]
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
@@ -89,68 +93,48 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
|
||||
"""Representation of an Satel Integra binary sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
device_number,
|
||||
device_name,
|
||||
zone_type,
|
||||
sensor_type,
|
||||
react_to_signal,
|
||||
config_entry_id,
|
||||
):
|
||||
controller: AsyncSatel,
|
||||
device_number: int,
|
||||
device_name: str,
|
||||
device_class: BinarySensorDeviceClass,
|
||||
sensor_type: str,
|
||||
react_to_signal: str,
|
||||
config_entry_id: str,
|
||||
) -> None:
|
||||
"""Initialize the binary_sensor."""
|
||||
self._device_number = device_number
|
||||
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
|
||||
self._name = device_name
|
||||
self._zone_type = zone_type
|
||||
self._state = 0
|
||||
self._react_to_signal = react_to_signal
|
||||
self._satel = controller
|
||||
|
||||
self._attr_device_class = device_class
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
|
||||
if self._device_number in self._satel.violated_outputs:
|
||||
self._state = 1
|
||||
else:
|
||||
self._state = 0
|
||||
elif self._device_number in self._satel.violated_zones:
|
||||
self._state = 1
|
||||
self._attr_is_on = self._device_number in self._satel.violated_outputs
|
||||
else:
|
||||
self._state = 0
|
||||
self._attr_is_on = self._device_number in self._satel.violated_zones
|
||||
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, self._react_to_signal, self._devices_updated
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
"""Icon for device by its type."""
|
||||
if self._zone_type is BinarySensorDeviceClass.SMOKE:
|
||||
return "mdi:fire"
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state == 1
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._zone_type
|
||||
|
||||
@callback
|
||||
def _devices_updated(self, zones):
|
||||
def _devices_updated(self, zones: dict[int, int]):
|
||||
"""Update the zone's state, if needed."""
|
||||
if self._device_number in zones and self._state != zones[self._device_number]:
|
||||
self._state = zones[self._device_number]
|
||||
self.async_write_ha_state()
|
||||
if self._device_number in zones:
|
||||
new_state = zones[self._device_number] == 1
|
||||
if new_state != self._attr_is_on:
|
||||
self._attr_is_on = new_state
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pysmartthings"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pysmartthings==3.3.1"]
|
||||
"requirements": ["pysmartthings==3.3.2"]
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.json import json_loads
|
||||
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBitmapBitWrapper, DPCodeBooleanWrapper, DPCodeWrapper
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -366,20 +366,48 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
|
||||
}
|
||||
|
||||
|
||||
def _get_bitmap_bit_mask(
|
||||
device: CustomerDevice, dpcode: str, bitmap_key: str | None
|
||||
) -> int | None:
|
||||
"""Get the bit mask for a given bitmap description."""
|
||||
if (
|
||||
bitmap_key is None
|
||||
or (status_range := device.status_range.get(dpcode)) is None
|
||||
or status_range.type != DPType.BITMAP
|
||||
or not isinstance(bitmap_values := json_loads(status_range.values), dict)
|
||||
or not isinstance(bitmap_labels := bitmap_values.get("label"), list)
|
||||
or bitmap_key not in bitmap_labels
|
||||
):
|
||||
class _CustomDPCodeWrapper(DPCodeWrapper):
|
||||
"""Custom DPCode Wrapper to check for values in a set."""
|
||||
|
||||
_valid_values: set[bool | float | int | str]
|
||||
|
||||
def __init__(
|
||||
self, dpcode: str, valid_values: set[bool | float | int | str]
|
||||
) -> None:
|
||||
"""Init CustomDPCodeBooleanWrapper."""
|
||||
super().__init__(dpcode)
|
||||
self._valid_values = valid_values
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return raw_value in self._valid_values
|
||||
|
||||
|
||||
def _get_dpcode_wrapper(
|
||||
device: CustomerDevice,
|
||||
description: TuyaBinarySensorEntityDescription,
|
||||
) -> DPCodeWrapper | None:
|
||||
"""Get DPCode wrapper for an entity description."""
|
||||
dpcode = description.dpcode or description.key
|
||||
if description.bitmap_key is not None:
|
||||
return DPCodeBitmapBitWrapper.find_dpcode(
|
||||
device, dpcode, bitmap_key=description.bitmap_key
|
||||
)
|
||||
|
||||
if bool_type := DPCodeBooleanWrapper.find_dpcode(device, dpcode):
|
||||
return bool_type
|
||||
|
||||
# Legacy / compatibility
|
||||
if dpcode not in device.status:
|
||||
return None
|
||||
return bitmap_labels.index(bitmap_key)
|
||||
return _CustomDPCodeWrapper(
|
||||
dpcode,
|
||||
description.on_value
|
||||
if isinstance(description.on_value, set)
|
||||
else {description.on_value},
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -397,25 +425,11 @@ async def async_setup_entry(
|
||||
for device_id in device_ids:
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := BINARY_SENSORS.get(device.category):
|
||||
for description in descriptions:
|
||||
dpcode = description.dpcode or description.key
|
||||
if dpcode in device.status:
|
||||
mask = _get_bitmap_bit_mask(
|
||||
device, dpcode, description.bitmap_key
|
||||
)
|
||||
|
||||
if (
|
||||
description.bitmap_key is None # Regular binary sensor
|
||||
or mask is not None # Bitmap sensor with valid mask
|
||||
):
|
||||
entities.append(
|
||||
TuyaBinarySensorEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
mask,
|
||||
)
|
||||
)
|
||||
entities.extend(
|
||||
TuyaBinarySensorEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -436,26 +450,15 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: TuyaBinarySensorEntityDescription,
|
||||
bit_mask: int | None = None,
|
||||
dpcode_wrapper: DPCodeWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya binary sensor."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._bit_mask = bit_mask
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if sensor is on."""
|
||||
dpcode = self.entity_description.dpcode or self.entity_description.key
|
||||
if dpcode not in self.device.status:
|
||||
return False
|
||||
|
||||
if self._bit_mask is not None:
|
||||
# For bitmap sensors, check the specific bit mask
|
||||
return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0
|
||||
|
||||
if isinstance(self.entity_description.on_value, set):
|
||||
return self.device.status[dpcode] in self.entity_description.on_value
|
||||
|
||||
return self.device.status[dpcode] == self.entity_description.on_value
|
||||
return self._dpcode_wrapper.read_device_status(self.device)
|
||||
|
||||
@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from . import TuyaConfigEntry
|
||||
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
|
||||
from .entity import TuyaEntity
|
||||
from .models import DPCodeBooleanWrapper
|
||||
|
||||
BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
|
||||
DeviceCategory.HXD: (
|
||||
@@ -21,6 +22,19 @@ BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
|
||||
translation_key="snooze",
|
||||
),
|
||||
),
|
||||
DeviceCategory.MSP: (
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.FACTORY_RESET,
|
||||
translation_key="factory_reset",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.MANUAL_CLEAN,
|
||||
translation_key="manual_clean",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.SD: (
|
||||
ButtonEntityDescription(
|
||||
key=DPCode.RESET_DUSTER_CLOTH,
|
||||
@@ -67,9 +81,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := BUTTONS.get(device.category):
|
||||
entities.extend(
|
||||
TuyaButtonEntity(device, manager, description)
|
||||
TuyaButtonEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
@@ -89,12 +107,14 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
|
||||
device: CustomerDevice,
|
||||
device_manager: Manager,
|
||||
description: ButtonEntityDescription,
|
||||
dpcode_wrapper: DPCodeBooleanWrapper,
|
||||
) -> None:
|
||||
"""Init Tuya button."""
|
||||
super().__init__(device, device_manager)
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{super().unique_id}{description.key}"
|
||||
self._dpcode_wrapper = dpcode_wrapper
|
||||
|
||||
def press(self) -> None:
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
self._send_command([{"code": self.entity_description.key, "value": True}])
|
||||
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
|
||||
|
||||
@@ -717,6 +717,7 @@ class DPCode(StrEnum):
|
||||
ELECTRICITY_LEFT = "electricity_left"
|
||||
EXCRETION_TIME_DAY = "excretion_time_day"
|
||||
EXCRETION_TIMES_DAY = "excretion_times_day"
|
||||
FACTORY_RESET = "factory_reset"
|
||||
FAN_BEEP = "fan_beep" # Sound
|
||||
FAN_COOL = "fan_cool" # Cool wind
|
||||
FAN_DIRECTION = "fan_direction" # Fan direction
|
||||
@@ -773,6 +774,7 @@ class DPCode(StrEnum):
|
||||
LIQUID_STATE = "liquid_state"
|
||||
LOCK = "lock" # Lock / Child lock
|
||||
MACH_OPERATE = "mach_operate"
|
||||
MANUAL_CLEAN = "manual_clean"
|
||||
MANUAL_FEED = "manual_feed"
|
||||
MASTER_MODE = "master_mode" # alarm mode
|
||||
MASTER_STATE = "master_state" # alarm state
|
||||
|
||||
@@ -240,6 +240,13 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
|
||||
color_data=DPCode.COLOUR_DATA,
|
||||
),
|
||||
),
|
||||
DeviceCategory.MSP: (
|
||||
TuyaLightEntityDescription(
|
||||
key=DPCode.LIGHT,
|
||||
translation_key="light",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
),
|
||||
DeviceCategory.QJDCZ: (
|
||||
TuyaLightEntityDescription(
|
||||
key=DPCode.SWITCH_LED,
|
||||
|
||||
@@ -22,17 +22,18 @@ class TypeInformation:
|
||||
As provided by the SDK, from `device.function` / `device.status_range`.
|
||||
"""
|
||||
|
||||
dpcode: DPCode
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a TypeInformation object."""
|
||||
raise NotImplementedError("from_json is not implemented for this type")
|
||||
return cls(dpcode)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegerTypeData(TypeInformation):
|
||||
"""Integer Type Data."""
|
||||
|
||||
dpcode: DPCode
|
||||
min: int
|
||||
max: int
|
||||
scale: float
|
||||
@@ -100,11 +101,24 @@ class IntegerTypeData(TypeInformation):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class BitmapTypeInformation(TypeInformation):
|
||||
"""Bitmap type information."""
|
||||
|
||||
label: list[str]
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
|
||||
"""Load JSON string and return a BitmapTypeInformation object."""
|
||||
if not (parsed := json.loads(data)):
|
||||
return None
|
||||
return cls(dpcode, **parsed)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnumTypeData(TypeInformation):
|
||||
"""Enum Type Data."""
|
||||
|
||||
dpcode: DPCode
|
||||
range: list[str]
|
||||
|
||||
@classmethod
|
||||
@@ -116,6 +130,8 @@ class EnumTypeData(TypeInformation):
|
||||
|
||||
|
||||
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
|
||||
DPType.BITMAP: BitmapTypeInformation,
|
||||
DPType.BOOLEAN: TypeInformation,
|
||||
DPType.ENUM: EnumTypeData,
|
||||
DPType.INTEGER: IntegerTypeData,
|
||||
}
|
||||
@@ -146,13 +162,13 @@ class DPCodeWrapper(ABC):
|
||||
The raw device status is converted to a Home Assistant value.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
|
||||
"""Convert a Home Assistant value back to a raw device value.
|
||||
|
||||
This is called by `get_update_command` to prepare the value for sending
|
||||
back to the device, and should be implemented in concrete classes.
|
||||
back to the device, and should be implemented in concrete classes if needed.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
|
||||
"""Get the update command for the dpcode.
|
||||
@@ -165,29 +181,6 @@ class DPCodeWrapper(ABC):
|
||||
}
|
||||
|
||||
|
||||
class DPCodeBooleanWrapper(DPCodeWrapper):
|
||||
"""Simple wrapper for boolean values.
|
||||
|
||||
Supports True/False only.
|
||||
"""
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) in (True, False):
|
||||
return raw_value
|
||||
return None
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: Any
|
||||
) -> Any | None:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
if value in (True, False):
|
||||
return value
|
||||
# Currently only called with boolean values
|
||||
# Safety net in case of future changes
|
||||
raise ValueError(f"Invalid boolean value `{value}`")
|
||||
|
||||
|
||||
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
"""Base DPCode wrapper with Type Information."""
|
||||
|
||||
@@ -217,6 +210,31 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
|
||||
return None
|
||||
|
||||
|
||||
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
|
||||
"""Simple wrapper for boolean values.
|
||||
|
||||
Supports True/False only.
|
||||
"""
|
||||
|
||||
DPTYPE = DPType.BOOLEAN
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) in (True, False):
|
||||
return raw_value
|
||||
return None
|
||||
|
||||
def _convert_value_to_raw_value(
|
||||
self, device: CustomerDevice, value: Any
|
||||
) -> Any | None:
|
||||
"""Convert a Home Assistant value back to a raw device value."""
|
||||
if value in (True, False):
|
||||
return value
|
||||
# Currently only called with boolean values
|
||||
# Safety net in case of future changes
|
||||
raise ValueError(f"Invalid boolean value `{value}`")
|
||||
|
||||
|
||||
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
|
||||
"""Simple wrapper for EnumTypeData values."""
|
||||
|
||||
@@ -272,6 +290,48 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
|
||||
)
|
||||
|
||||
|
||||
class DPCodeBitmapBitWrapper(DPCodeWrapper):
|
||||
"""Simple wrapper for a specific bit in bitmap values."""
|
||||
|
||||
def __init__(self, dpcode: str, mask: int) -> None:
|
||||
"""Init DPCodeBitmapWrapper."""
|
||||
super().__init__(dpcode)
|
||||
self._mask = mask
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> bool | None:
|
||||
"""Read the device value for the dpcode."""
|
||||
if (raw_value := self._read_device_status_raw(device)) is None:
|
||||
return None
|
||||
return (raw_value & (1 << self._mask)) != 0
|
||||
|
||||
@classmethod
|
||||
def find_dpcode(
|
||||
cls,
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...],
|
||||
*,
|
||||
bitmap_key: str,
|
||||
) -> Self | None:
|
||||
"""Find and return a DPCodeBitmapBitWrapper for the given DP codes."""
|
||||
if (
|
||||
type_information := find_dpcode(device, dpcodes, dptype=DPType.BITMAP)
|
||||
) and bitmap_key in type_information.label:
|
||||
return cls(
|
||||
type_information.dpcode, type_information.label.index(bitmap_key)
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
|
||||
*,
|
||||
prefer_function: bool = False,
|
||||
dptype: Literal[DPType.BITMAP],
|
||||
) -> BitmapTypeInformation | None: ...
|
||||
|
||||
|
||||
@overload
|
||||
def find_dpcode(
|
||||
device: CustomerDevice,
|
||||
|
||||
@@ -77,6 +77,12 @@
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"factory_reset": {
|
||||
"name": "Factory reset"
|
||||
},
|
||||
"manual_clean": {
|
||||
"name": "Manual clean"
|
||||
},
|
||||
"reset_duster_cloth": {
|
||||
"name": "Reset duster cloth"
|
||||
},
|
||||
|
||||
@@ -946,14 +946,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := SWITCHES.get(device.category):
|
||||
entities.extend(
|
||||
TuyaSwitchEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
DPCodeBooleanWrapper(description.key),
|
||||
)
|
||||
TuyaSwitchEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
and _check_deprecation(
|
||||
hass,
|
||||
device,
|
||||
|
||||
@@ -94,14 +94,13 @@ async def async_setup_entry(
|
||||
device = manager.device_map[device_id]
|
||||
if descriptions := VALVES.get(device.category):
|
||||
entities.extend(
|
||||
TuyaValveEntity(
|
||||
device,
|
||||
manager,
|
||||
description,
|
||||
DPCodeBooleanWrapper(description.key),
|
||||
)
|
||||
TuyaValveEntity(device, manager, description, dpcode_wrapper)
|
||||
for description in descriptions
|
||||
if description.key in device.status
|
||||
if (
|
||||
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
|
||||
device, description.key, prefer_function=True
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
@@ -35,6 +35,7 @@ class VeluxLight(VeluxEntity, LightEntity):
|
||||
|
||||
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
|
||||
_attr_color_mode = ColorMode.BRIGHTNESS
|
||||
_attr_name = None
|
||||
|
||||
node: LighteningDevice
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.exceptions import (
|
||||
)
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -65,7 +66,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bo
|
||||
async def _async_auth_and_create_api(
|
||||
hass: HomeAssistant, entry: VolvoConfigEntry
|
||||
) -> VolvoCarsApi:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth2_implementation_unavailable",
|
||||
) from err
|
||||
oauth_session = OAuth2Session(hass, entry, implementation)
|
||||
web_session = async_get_clientsession(hass)
|
||||
auth = VolvoAuth(web_session, oauth_session)
|
||||
|
||||
@@ -362,6 +362,9 @@
|
||||
"no_vehicle": {
|
||||
"message": "Unable to retrieve vehicle details."
|
||||
},
|
||||
"oauth2_implementation_unavailable": {
|
||||
"message": "OAuth2 implementation unavailable, will retry"
|
||||
},
|
||||
"unauthorized": {
|
||||
"message": "Authentication failed. {message}"
|
||||
},
|
||||
|
||||
@@ -1304,7 +1304,11 @@ def issues(hass: HomeAssistant) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
"""Return all open issues."""
|
||||
current_issues = ir.async_get(hass).issues
|
||||
# Use JSON for safe representation
|
||||
return {k: v.to_json() for (k, v) in current_issues.items()}
|
||||
return {
|
||||
key: issue_entry.to_json()
|
||||
for (key, issue_entry) in current_issues.items()
|
||||
if issue_entry.active
|
||||
}
|
||||
|
||||
|
||||
def issue(hass: HomeAssistant, domain: str, issue_id: str) -> dict[str, Any] | None:
|
||||
|
||||
6
requirements_all.txt
generated
6
requirements_all.txt
generated
@@ -315,7 +315,7 @@ aiolookin==1.0.0
|
||||
aiolyric==2.0.2
|
||||
|
||||
# homeassistant.components.mealie
|
||||
aiomealie==1.0.1
|
||||
aiomealie==1.1.0
|
||||
|
||||
# homeassistant.components.modern_forms
|
||||
aiomodernforms==0.1.8
|
||||
@@ -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
|
||||
|
||||
6
requirements_test_all.txt
generated
6
requirements_test_all.txt
generated
@@ -297,7 +297,7 @@ aiolookin==1.0.0
|
||||
aiolyric==2.0.2
|
||||
|
||||
# homeassistant.components.mealie
|
||||
aiomealie==1.0.1
|
||||
aiomealie==1.1.0
|
||||
|
||||
# homeassistant.components.modern_forms
|
||||
aiomodernforms==0.1.8
|
||||
@@ -1885,7 +1885,7 @@ pyotp==2.9.0
|
||||
pyoverkiz==1.19.0
|
||||
|
||||
# homeassistant.components.palazzetti
|
||||
pypalazzetti==0.1.19
|
||||
pypalazzetti==0.1.20
|
||||
|
||||
# homeassistant.components.paperless_ngx
|
||||
pypaperless==4.1.1
|
||||
@@ -1982,7 +1982,7 @@ pysmappee==0.2.29
|
||||
pysmarlaapi==0.9.2
|
||||
|
||||
# homeassistant.components.smartthings
|
||||
pysmartthings==3.3.1
|
||||
pysmartthings==3.3.2
|
||||
|
||||
# homeassistant.components.smarty
|
||||
pysmarty2==0.10.3
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Test Ambient Weather Network sensors."""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from aioambient import OpenAPI
|
||||
from aioambient.errors import RequestError
|
||||
from freezegun import freeze_time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -18,7 +18,7 @@ from .conftest import setup_platform
|
||||
from tests.common import async_fire_time_changed, snapshot_platform
|
||||
|
||||
|
||||
@freeze_time("2023-11-9")
|
||||
@pytest.mark.freeze_time("2023-11-9")
|
||||
@pytest.mark.parametrize(
|
||||
"config_entry",
|
||||
["AA:AA:AA:AA:AA:AA", "CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD"],
|
||||
@@ -54,45 +54,43 @@ async def test_sensors_with_no_data(
|
||||
|
||||
|
||||
@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True)
|
||||
@pytest.mark.freeze_time("2023-11-8")
|
||||
async def test_sensors_disappearing(
|
||||
hass: HomeAssistant,
|
||||
open_api: OpenAPI,
|
||||
aioambient: AsyncMock,
|
||||
config_entry: ConfigEntry,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test that we log errors properly."""
|
||||
|
||||
initial_datetime = datetime(year=2023, month=11, day=8)
|
||||
with freeze_time(initial_datetime) as frozen_datetime:
|
||||
# Normal state, sensor is available.
|
||||
await setup_platform(True, hass, config_entry)
|
||||
# Normal state, sensor is available.
|
||||
await setup_platform(True, hass, config_entry)
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert float(sensor.state) == pytest.approx(1001.89694313129)
|
||||
|
||||
# Sensor becomes unavailable if the network is unavailable. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
with patch.object(open_api, "get_device_details", side_effect=RequestError()):
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "unavailable"
|
||||
assert caplog.text.count("Cannot connect to Ambient Network") == 1
|
||||
|
||||
# Network comes back. Sensor should start reporting again. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
freezer.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert float(sensor.state) == pytest.approx(1001.89694313129)
|
||||
|
||||
# Sensor becomes unavailable if the network is unavailable. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
with patch.object(
|
||||
open_api, "get_device_details", side_effect=RequestError()
|
||||
):
|
||||
frozen_datetime.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert sensor.state == "unavailable"
|
||||
assert caplog.text.count("Cannot connect to Ambient Network") == 1
|
||||
|
||||
# Network comes back. Sensor should start reporting again. Log message
|
||||
# should only show up once.
|
||||
for _ in range(5):
|
||||
frozen_datetime.tick(timedelta(minutes=10))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
sensor = hass.states.get("sensor.station_a_relative_pressure")
|
||||
assert sensor is not None
|
||||
assert float(sensor.state) == pytest.approx(1001.89694313129)
|
||||
assert caplog.text.count("Fetching ambient_network data recovered") == 1
|
||||
assert caplog.text.count("Fetching ambient_network data recovered") == 1
|
||||
|
||||
@@ -1 +1,168 @@
|
||||
"""Tests for the Anthropic integration."""
|
||||
|
||||
from anthropic.types import (
|
||||
CitationsDelta,
|
||||
InputJSONDelta,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageStreamEvent,
|
||||
RedactedThinkingBlock,
|
||||
ServerToolUseBlock,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextCitation,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingDelta,
|
||||
ToolUseBlock,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultBlock,
|
||||
)
|
||||
|
||||
|
||||
def create_content_block(
|
||||
index: int, text_parts: list[str], citations: list[TextCitation] | None = None
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a text content block with the specified deltas."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=TextBlock(
|
||||
text="", type="text", citations=[] if citations else None
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=CitationsDelta(citation=citation, type="citations_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for citation in (citations or [])
|
||||
],
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=TextDelta(text=text_part, type="text_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for text_part in text_parts
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_thinking_block(
|
||||
index: int, thinking_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a thinking block with the specified deltas."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ThinkingBlock(signature="", thinking="", type="thinking"),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=ThinkingDelta(thinking=thinking_part, type="thinking_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for thinking_part in thinking_parts
|
||||
],
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=SignatureDelta(
|
||||
signature="ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/N"
|
||||
"oB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ"
|
||||
"4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo"
|
||||
"21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==",
|
||||
type="signature_delta",
|
||||
),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_redacted_thinking_block(index: int) -> list[RawMessageStreamEvent]:
|
||||
"""Create a redacted thinking block."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=RedactedThinkingBlock(
|
||||
data="EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9K"
|
||||
"WPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeV"
|
||||
"sJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOK"
|
||||
"iKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny",
|
||||
type="redacted_thinking",
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_tool_use_block(
|
||||
index: int, tool_id: str, tool_name: str, json_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a tool use content block with the specified deltas."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ToolUseBlock(
|
||||
id=tool_id, name=tool_name, input={}, type="tool_use"
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for json_part in json_parts
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_web_search_block(
|
||||
index: int, id: str, query_parts: list[str]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool use block for web search."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=ServerToolUseBlock(
|
||||
type="server_tool_use", id=id, input={}, name="web_search"
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
*[
|
||||
RawContentBlockDeltaEvent(
|
||||
delta=InputJSONDelta(type="input_json_delta", partial_json=query_part),
|
||||
index=index,
|
||||
type="content_block_delta",
|
||||
)
|
||||
for query_part in query_parts
|
||||
],
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
|
||||
def create_web_search_result_block(
|
||||
index: int, id: str, results: list[WebSearchResultBlock]
|
||||
) -> list[RawMessageStreamEvent]:
|
||||
"""Create a server tool result block for web search results."""
|
||||
return [
|
||||
RawContentBlockStartEvent(
|
||||
type="content_block_start",
|
||||
content_block=WebSearchToolResultBlock(
|
||||
type="web_search_tool_result", tool_use_id=id, content=results
|
||||
),
|
||||
index=index,
|
||||
),
|
||||
RawContentBlockStopEvent(index=index, type="content_block_stop"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
"""Tests helpers."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import patch
|
||||
from collections.abc import AsyncGenerator, Generator, Iterable
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from anthropic.types import (
|
||||
Message,
|
||||
MessageDeltaUsage,
|
||||
RawContentBlockStartEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RawMessageStreamEvent,
|
||||
ToolUseBlock,
|
||||
Usage,
|
||||
)
|
||||
from anthropic.types.raw_message_delta_event import Delta
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.anthropic import CONF_CHAT_MODEL
|
||||
@@ -14,6 +26,7 @@ from homeassistant.components.anthropic.const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
)
|
||||
from homeassistant.const import CONF_LLM_HASS_API
|
||||
@@ -40,7 +53,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"subentry_type": "conversation",
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"data": {},
|
||||
"subentry_type": "ai_task_data",
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
@@ -114,3 +133,61 @@ async def mock_init_component(
|
||||
async def setup_ha(hass: HomeAssistant) -> None:
|
||||
"""Set up Home Assistant."""
|
||||
assert await async_setup_component(hass, "homeassistant", {})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_create_stream() -> Generator[AsyncMock]:
|
||||
"""Mock stream response."""
|
||||
|
||||
async def mock_generator(events: Iterable[RawMessageStreamEvent], **kwargs):
|
||||
"""Create a stream of messages with the specified content blocks."""
|
||||
stop_reason = "end_turn"
|
||||
refusal_magic_string = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86"
|
||||
for message in kwargs.get("messages"):
|
||||
if message["role"] != "user":
|
||||
continue
|
||||
if isinstance(message["content"], str):
|
||||
if refusal_magic_string in message["content"]:
|
||||
stop_reason = "refusal"
|
||||
break
|
||||
else:
|
||||
for content in message["content"]:
|
||||
if content.get(
|
||||
"type"
|
||||
) == "text" and refusal_magic_string in content.get("text", ""):
|
||||
stop_reason = "refusal"
|
||||
break
|
||||
|
||||
yield RawMessageStartEvent(
|
||||
message=Message(
|
||||
type="message",
|
||||
id="msg_1234567890ABCDEFGHIJKLMN",
|
||||
content=[],
|
||||
role="assistant",
|
||||
model="claude-3-5-sonnet-20240620",
|
||||
usage=Usage(input_tokens=0, output_tokens=0),
|
||||
),
|
||||
type="message_start",
|
||||
)
|
||||
for event in events:
|
||||
if isinstance(event, RawContentBlockStartEvent) and isinstance(
|
||||
event.content_block, ToolUseBlock
|
||||
):
|
||||
stop_reason = "tool_use"
|
||||
yield event
|
||||
yield RawMessageDeltaEvent(
|
||||
type="message_delta",
|
||||
delta=Delta(stop_reason=stop_reason, stop_sequence=""),
|
||||
usage=MessageDeltaUsage(output_tokens=0),
|
||||
)
|
||||
yield RawMessageStopEvent(type="message_stop")
|
||||
|
||||
with patch(
|
||||
"anthropic.resources.messages.AsyncMessages.create",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_create:
|
||||
mock_create.side_effect = lambda **kwargs: mock_generator(
|
||||
mock_create.return_value.pop(0), **kwargs
|
||||
)
|
||||
|
||||
yield mock_create
|
||||
|
||||
211
tests/components/anthropic/test_ai_task.py
Normal file
211
tests/components/anthropic/test_ai_task.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""Tests for the Anthropic integration."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import ai_task, media_source
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from . import create_content_block, create_tool_use_block
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_generate_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test AI Task data generation."""
|
||||
entity_id = "ai_task.claude_ai_task"
|
||||
|
||||
# Ensure entity is linked to the subentry
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
ai_task_entry = next(
|
||||
iter(
|
||||
entry
|
||||
for entry in mock_config_entry.subentries.values()
|
||||
if entry.subentry_type == "ai_task_data"
|
||||
)
|
||||
)
|
||||
assert entity_entry is not None
|
||||
assert entity_entry.config_entry_id == mock_config_entry.entry_id
|
||||
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
|
||||
|
||||
mock_create_stream.return_value = [create_content_block(0, ["The test data"])]
|
||||
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Generate test data",
|
||||
)
|
||||
|
||||
assert result.data == "The test data"
|
||||
|
||||
|
||||
async def test_generate_structured_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test AI Task structured data generation."""
|
||||
mock_create_stream.return_value = [
|
||||
create_tool_use_block(
|
||||
1,
|
||||
"toolu_0123456789AbCdEfGhIjKlM",
|
||||
"test_task",
|
||||
['{"charac', 'ters": ["Mario', '", "Luigi"]}'],
|
||||
),
|
||||
]
|
||||
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.Required("characters"): selector.selector(
|
||||
{
|
||||
"text": {
|
||||
"multiple": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
assert result.data == {"characters": ["Mario", "Luigi"]}
|
||||
|
||||
|
||||
async def test_generate_invalid_structured_data(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
) -> None:
|
||||
"""Test AI Task with invalid JSON response."""
|
||||
mock_create_stream.return_value = [
|
||||
create_tool_use_block(
|
||||
1,
|
||||
"toolu_0123456789AbCdEfGhIjKlM",
|
||||
"test_task",
|
||||
"INVALID JSON RESPONSE",
|
||||
)
|
||||
]
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="Error with Claude structured response"
|
||||
):
|
||||
await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id="ai_task.claude_ai_task",
|
||||
instructions="Generate test data",
|
||||
structure=vol.Schema(
|
||||
{
|
||||
vol.Required("characters"): selector.selector(
|
||||
{
|
||||
"text": {
|
||||
"multiple": True,
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def test_generate_data_with_attachments(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
mock_create_stream: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test AI Task data generation with attachments."""
|
||||
entity_id = "ai_task.claude_ai_task"
|
||||
|
||||
mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])]
|
||||
|
||||
# Test with attachments
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.media_source.async_resolve_media",
|
||||
side_effect=[
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/doorbell_snapshot.jpg",
|
||||
mime_type="image/jpeg",
|
||||
path=Path("doorbell_snapshot.jpg"),
|
||||
),
|
||||
media_source.PlayMedia(
|
||||
url="http://example.com/context.pdf",
|
||||
mime_type="application/pdf",
|
||||
path=Path("context.pdf"),
|
||||
),
|
||||
],
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch(
|
||||
"homeassistant.components.openai_conversation.entity.guess_file_type",
|
||||
return_value=("image/jpeg", None),
|
||||
),
|
||||
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
|
||||
):
|
||||
result = await ai_task.async_generate_data(
|
||||
hass,
|
||||
task_name="Test Task",
|
||||
entity_id=entity_id,
|
||||
instructions="Test prompt",
|
||||
attachments=[
|
||||
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
|
||||
{"media_content_id": "media-source://media/context.pdf"},
|
||||
],
|
||||
)
|
||||
|
||||
assert result.data == "Hi there!"
|
||||
|
||||
# Verify that the create stream was called with the correct parameters
|
||||
# The last call should have the user message with attachments
|
||||
call_args = mock_create_stream.call_args
|
||||
assert call_args is not None
|
||||
|
||||
# Check that the input includes the attachments
|
||||
input_messages = call_args[1]["messages"]
|
||||
assert len(input_messages) > 0
|
||||
|
||||
# Find the user message with attachments
|
||||
user_message_with_attachments = input_messages[-2]
|
||||
|
||||
assert user_message_with_attachments is not None
|
||||
assert isinstance(user_message_with_attachments["content"], list)
|
||||
assert len(user_message_with_attachments["content"]) == 3 # Text + attachments
|
||||
assert user_message_with_attachments["content"] == [
|
||||
{"type": "text", "text": "Test prompt"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "image/jpeg",
|
||||
"type": "base64",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"data": "ZmFrZV9pbWFnZV9kYXRh",
|
||||
"media_type": "application/pdf",
|
||||
"type": "base64",
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -15,7 +15,10 @@ from httpx import URL, Request, Response
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.anthropic.config_flow import RECOMMENDED_OPTIONS
|
||||
from homeassistant.components.anthropic.config_flow import (
|
||||
RECOMMENDED_AI_TASK_OPTIONS,
|
||||
RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
)
|
||||
from homeassistant.components.anthropic.const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
@@ -30,6 +33,7 @@ from homeassistant.components.anthropic.const import (
|
||||
CONF_WEB_SEARCH_REGION,
|
||||
CONF_WEB_SEARCH_TIMEZONE,
|
||||
CONF_WEB_SEARCH_USER_LOCATION,
|
||||
DEFAULT_AI_TASK_NAME,
|
||||
DEFAULT_CONVERSATION_NAME,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
@@ -74,7 +78,6 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
"api_key": "bla",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["data"] == {
|
||||
@@ -84,10 +87,16 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
assert result2["subentries"] == [
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_OPTIONS,
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
}
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
]
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -135,14 +144,13 @@ async def test_creating_conversation_subentry(
|
||||
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS},
|
||||
{CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result2["type"] is FlowResultType.CREATE_ENTRY
|
||||
assert result2["title"] == "Mock name"
|
||||
|
||||
processed_options = RECOMMENDED_OPTIONS.copy()
|
||||
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
|
||||
|
||||
assert result2["data"] == processed_options
|
||||
@@ -302,7 +310,6 @@ async def test_subentry_web_search_user_location(
|
||||
"user_location": True,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert (
|
||||
mock_create.call_args.kwargs["messages"][0]["content"] == "Where are the "
|
||||
@@ -557,3 +564,122 @@ async def test_subentry_options_switching(
|
||||
assert subentry_flow["type"] is FlowResultType.ABORT
|
||||
assert subentry_flow["reason"] == "reconfigure_successful"
|
||||
assert subentry.data == expected_options
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry."""
|
||||
old_subentries = set(mock_config_entry.subentries)
|
||||
# Original conversation + original ai_task
|
||||
assert len(mock_config_entry.subentries) == 2
|
||||
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "init"
|
||||
assert not result.get("errors")
|
||||
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"name": "Custom AI Task",
|
||||
CONF_RECOMMENDED: True,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert result2.get("title") == "Custom AI Task"
|
||||
assert result2.get("data") == {
|
||||
CONF_RECOMMENDED: True,
|
||||
}
|
||||
|
||||
assert (
|
||||
len(mock_config_entry.subentries) == 3
|
||||
) # Original conversation + original ai_task + new ai_task
|
||||
|
||||
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
|
||||
new_subentry = mock_config_entry.subentries[new_subentry_id]
|
||||
assert new_subentry.subentry_type == "ai_task_data"
|
||||
assert new_subentry.title == "Custom AI Task"
|
||||
|
||||
|
||||
async def test_ai_task_subentry_not_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry when entry is not loaded."""
|
||||
# Don't call mock_init_component to simulate not loaded state
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.ABORT
|
||||
assert result.get("reason") == "entry_not_loaded"
|
||||
|
||||
|
||||
async def test_creating_ai_task_subentry_advanced(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_init_component,
|
||||
) -> None:
|
||||
"""Test creating an AI task subentry with advanced settings."""
|
||||
result = await hass.config_entries.subentries.async_init(
|
||||
(mock_config_entry.entry_id, "ai_task_data"),
|
||||
context={"source": config_entries.SOURCE_USER},
|
||||
)
|
||||
|
||||
assert result.get("type") is FlowResultType.FORM
|
||||
assert result.get("step_id") == "init"
|
||||
|
||||
# Go to advanced settings
|
||||
result2 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
"name": "Advanced AI Task",
|
||||
CONF_RECOMMENDED: False,
|
||||
},
|
||||
)
|
||||
|
||||
assert result2.get("type") is FlowResultType.FORM
|
||||
assert result2.get("step_id") == "advanced"
|
||||
|
||||
# Configure advanced settings
|
||||
result3 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-5",
|
||||
CONF_MAX_TOKENS: 200,
|
||||
CONF_TEMPERATURE: 0.5,
|
||||
},
|
||||
)
|
||||
|
||||
assert result3.get("type") is FlowResultType.FORM
|
||||
assert result3.get("step_id") == "model"
|
||||
|
||||
# Configure model settings
|
||||
result4 = await hass.config_entries.subentries.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_WEB_SEARCH: False,
|
||||
},
|
||||
)
|
||||
|
||||
assert result4.get("type") is FlowResultType.CREATE_ENTRY
|
||||
assert result4.get("title") == "Advanced AI Task"
|
||||
assert result4.get("data") == {
|
||||
CONF_RECOMMENDED: False,
|
||||
CONF_CHAT_MODEL: "claude-sonnet-4-5",
|
||||
CONF_MAX_TOKENS: 200,
|
||||
CONF_TEMPERATURE: 0.5,
|
||||
CONF_WEB_SEARCH: False,
|
||||
CONF_WEB_SEARCH_MAX_USES: 5,
|
||||
CONF_WEB_SEARCH_USER_LOCATION: False,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
"recipeCategory": [],
|
||||
"tags": [],
|
||||
"tools": [],
|
||||
"rating": null,
|
||||
"rating": 5.0,
|
||||
"orgURL": "https://tastesbetterfromscratch.com/roast-chicken/",
|
||||
"dateAdded": "2024-01-21",
|
||||
"dateUpdated": "2024-01-21T15:29:25.664540",
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
"recipeCategory": [],
|
||||
"tags": [],
|
||||
"tools": [],
|
||||
"rating": null,
|
||||
"rating": 5.0,
|
||||
"orgURL": "https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/",
|
||||
"dateAdded": "2024-01-21",
|
||||
"dateUpdated": "2024-01-21T10:27:39.409746",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
'original_url': 'https://tastesbetterfromscratch.com/roast-chicken/',
|
||||
'perform_time': '1 Hour 20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '5b055066-d57d-4fd0-8dfd-a2c2f07b36f1',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'roast-chicken',
|
||||
@@ -55,6 +56,7 @@
|
||||
'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'zoete-aardappel-curry-traybake',
|
||||
@@ -83,6 +85,7 @@
|
||||
'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno',
|
||||
'perform_time': '50 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1',
|
||||
@@ -111,6 +114,7 @@
|
||||
'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '40 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce',
|
||||
@@ -139,6 +143,7 @@
|
||||
'original_url': 'https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963',
|
||||
'perform_time': '1 Hour',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': 3.0,
|
||||
'recipe_id': '92635fd0-f2dc-4e78-a6e4-ecd556ad361f',
|
||||
'recipe_yield': '12 servings',
|
||||
'slug': 'pampered-chef-double-chocolate-mocha-trifle',
|
||||
@@ -167,6 +172,7 @@
|
||||
'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/',
|
||||
'perform_time': '22 Minutes',
|
||||
'prep_time': '8 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22',
|
||||
'recipe_yield': '24 servings',
|
||||
'slug': 'cheeseburger-sliders-easy-30-min-recipe',
|
||||
@@ -195,6 +201,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe',
|
||||
'perform_time': '3 Hours 10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'all-american-beef-stew-recipe',
|
||||
@@ -223,6 +230,7 @@
|
||||
'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'einfacher-nudelauflauf-mit-brokkoli',
|
||||
@@ -251,6 +259,7 @@
|
||||
'original_url': 'https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '25b814f2-d9bf-4df0-b40d-d2f2457b4317',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'miso-udon-noodles-with-spinach-and-tofu',
|
||||
@@ -279,6 +288,7 @@
|
||||
'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon',
|
||||
'perform_time': '2 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb',
|
||||
'recipe_yield': '12 servings',
|
||||
'slug': 'mousse-de-saumon',
|
||||
@@ -323,6 +333,7 @@
|
||||
'original_url': 'https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos',
|
||||
'perform_time': '7 Minutes',
|
||||
'prep_time': '3 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'e360a0cc-18b0-4a84-a91b-8aa59e2451c9',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido',
|
||||
@@ -351,6 +362,7 @@
|
||||
'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx',
|
||||
'perform_time': '4 Hours',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'boeuf-bourguignon-la-vraie-recette-2',
|
||||
@@ -379,6 +391,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe',
|
||||
'perform_time': '3 Hours 10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'all-american-beef-stew-recipe',
|
||||
@@ -409,6 +422,7 @@
|
||||
'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'einfacher-nudelauflauf-mit-brokkoli',
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'e82f5449-c33b-437c-b712-337587199264',
|
||||
'recipe_yield': None,
|
||||
'slug': 'tu6y',
|
||||
@@ -27,6 +28,7 @@
|
||||
'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno',
|
||||
'perform_time': '50 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1',
|
||||
@@ -42,6 +44,7 @@
|
||||
'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': 5.0,
|
||||
'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b',
|
||||
'recipe_yield': '',
|
||||
'slug': 'patates-douces-au-four-1',
|
||||
@@ -57,6 +60,7 @@
|
||||
'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '98845807-9365-41fd-acd1-35630b468c27',
|
||||
'recipe_yield': '',
|
||||
'slug': 'sweet-potatoes',
|
||||
@@ -72,6 +76,7 @@
|
||||
'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno',
|
||||
'perform_time': '50 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno',
|
||||
@@ -87,6 +92,7 @@
|
||||
'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx',
|
||||
'perform_time': '4 Hours',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'boeuf-bourguignon-la-vraie-recette-2',
|
||||
@@ -102,6 +108,7 @@
|
||||
'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx',
|
||||
'perform_time': '4 Hours',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'boeuf-bourguignon-la-vraie-recette-1',
|
||||
@@ -117,6 +124,7 @@
|
||||
'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/',
|
||||
'perform_time': '55 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91',
|
||||
'recipe_yield': '14 servings',
|
||||
'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter',
|
||||
@@ -132,6 +140,7 @@
|
||||
'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51',
|
||||
'recipe_yield': '',
|
||||
'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin',
|
||||
@@ -147,6 +156,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542',
|
||||
'recipe_yield': None,
|
||||
'slug': 'test123',
|
||||
@@ -162,6 +172,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78',
|
||||
'recipe_yield': None,
|
||||
'slug': 'bureeto',
|
||||
@@ -177,6 +188,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0',
|
||||
'recipe_yield': None,
|
||||
'slug': 'subway-double-cookies',
|
||||
@@ -192,6 +204,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4',
|
||||
'recipe_yield': None,
|
||||
'slug': 'qwerty12345',
|
||||
@@ -207,6 +220,7 @@
|
||||
'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/',
|
||||
'perform_time': '22 Minutes',
|
||||
'prep_time': '8 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22',
|
||||
'recipe_yield': '24 servings',
|
||||
'slug': 'cheeseburger-sliders-easy-30-min-recipe',
|
||||
@@ -222,6 +236,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291',
|
||||
'recipe_yield': '4',
|
||||
'slug': 'meatloaf',
|
||||
@@ -237,6 +252,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html',
|
||||
'perform_time': '2 Hours 20 Minutes',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': 3.0,
|
||||
'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'richtig-rheinischer-sauerbraten',
|
||||
@@ -252,6 +268,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'orientalischer-gemuse-hahnchen-eintopf',
|
||||
@@ -267,6 +284,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d',
|
||||
'recipe_yield': '4',
|
||||
'slug': 'test-20240121',
|
||||
@@ -282,6 +300,7 @@
|
||||
'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164',
|
||||
'recipe_yield': '',
|
||||
'slug': 'loempia-bowl',
|
||||
@@ -297,6 +316,7 @@
|
||||
'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/',
|
||||
'perform_time': None,
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': '5-ingredient-chocolate-mousse',
|
||||
@@ -312,6 +332,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html',
|
||||
'perform_time': '10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer',
|
||||
@@ -327,6 +348,7 @@
|
||||
'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/',
|
||||
'perform_time': '35min',
|
||||
'prep_time': '1h',
|
||||
'rating': None,
|
||||
'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842',
|
||||
'recipe_yield': '1',
|
||||
'slug': 'dinkel-sauerteigbrot',
|
||||
@@ -342,6 +364,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37',
|
||||
'recipe_yield': None,
|
||||
'slug': 'test-234234',
|
||||
@@ -357,6 +380,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688',
|
||||
'recipe_yield': None,
|
||||
'slug': 'test-243',
|
||||
@@ -372,6 +396,7 @@
|
||||
'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'einfacher-nudelauflauf-mit-brokkoli',
|
||||
@@ -398,6 +423,7 @@
|
||||
'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza',
|
||||
'perform_time': None,
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554',
|
||||
'recipe_yield': '8 servings',
|
||||
'slug': 'tarta-cytrynowa-z-beza',
|
||||
@@ -413,6 +439,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c',
|
||||
'recipe_yield': None,
|
||||
'slug': 'martins-test-recipe',
|
||||
@@ -428,6 +455,7 @@
|
||||
'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe',
|
||||
'perform_time': '30 Minutes',
|
||||
'prep_time': '25 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69',
|
||||
'recipe_yield': '12',
|
||||
'slug': 'muffinki-czekoladowe',
|
||||
@@ -443,6 +471,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf',
|
||||
'recipe_yield': None,
|
||||
'slug': 'my-test-recipe',
|
||||
@@ -458,6 +487,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816',
|
||||
'recipe_yield': None,
|
||||
'slug': 'my-test-receipe',
|
||||
@@ -473,6 +503,7 @@
|
||||
'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f',
|
||||
'recipe_yield': '',
|
||||
'slug': 'patates-douces-au-four',
|
||||
@@ -488,6 +519,7 @@
|
||||
'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '2 Hours 15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'easy-homemade-pizza-dough',
|
||||
@@ -503,6 +535,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe',
|
||||
'perform_time': '3 Hours 10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'all-american-beef-stew-recipe',
|
||||
@@ -518,6 +551,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe',
|
||||
'perform_time': '55 Minutes',
|
||||
'prep_time': '20 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce',
|
||||
@@ -533,6 +567,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html',
|
||||
'perform_time': '30 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'schnelle-kasespatzle',
|
||||
@@ -548,6 +583,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3',
|
||||
'recipe_yield': None,
|
||||
'slug': 'taco',
|
||||
@@ -563,6 +599,7 @@
|
||||
'original_url': 'https://www.ica.se/recept/vodkapasta-729011/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'vodkapasta',
|
||||
@@ -578,6 +615,7 @@
|
||||
'original_url': 'https://www.ica.se/recept/vodkapasta-729011/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'vodkapasta2',
|
||||
@@ -593,6 +631,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670',
|
||||
'recipe_yield': '1',
|
||||
'slug': 'rub',
|
||||
@@ -608,6 +647,7 @@
|
||||
'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523',
|
||||
'recipe_yield': '',
|
||||
'slug': 'banana-bread-chocolate-chip-cookies',
|
||||
@@ -623,6 +663,7 @@
|
||||
'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041',
|
||||
'recipe_yield': '',
|
||||
'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese',
|
||||
@@ -638,6 +679,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a',
|
||||
'recipe_yield': '',
|
||||
'slug': 'prova',
|
||||
@@ -653,6 +695,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4',
|
||||
'recipe_yield': None,
|
||||
'slug': 'pate-au-beurre-1',
|
||||
@@ -668,6 +711,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c',
|
||||
'recipe_yield': None,
|
||||
'slug': 'pate-au-beurre',
|
||||
@@ -683,6 +727,7 @@
|
||||
'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/',
|
||||
'perform_time': '1 Hour 30 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'sous-vide-cheesecake-recipe',
|
||||
@@ -698,6 +743,7 @@
|
||||
'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes',
|
||||
'perform_time': None,
|
||||
'prep_time': '30 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b',
|
||||
'recipe_yield': '10 servings',
|
||||
'slug': 'the-bomb-mini-cheesecakes',
|
||||
@@ -713,6 +759,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'tagliatelle-al-salmone',
|
||||
@@ -728,6 +775,7 @@
|
||||
'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html',
|
||||
'perform_time': '25 Minutes',
|
||||
'prep_time': '25 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627',
|
||||
'recipe_yield': '1 serving',
|
||||
'slug': 'death-by-chocolate',
|
||||
@@ -743,6 +791,7 @@
|
||||
'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'palak-dal-rezept-aus-indien',
|
||||
@@ -758,6 +807,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html',
|
||||
'perform_time': None,
|
||||
'prep_time': '30 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'tortelline-a-la-romana',
|
||||
@@ -781,6 +831,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'e82f5449-c33b-437c-b712-337587199264',
|
||||
'recipe_yield': None,
|
||||
'slug': 'tu6y',
|
||||
@@ -796,6 +847,7 @@
|
||||
'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno',
|
||||
'perform_time': '50 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1',
|
||||
@@ -811,6 +863,7 @@
|
||||
'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': 5.0,
|
||||
'recipe_id': '90097c8b-9d80-468a-b497-73957ac0cd8b',
|
||||
'recipe_yield': '',
|
||||
'slug': 'patates-douces-au-four-1',
|
||||
@@ -826,6 +879,7 @@
|
||||
'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '98845807-9365-41fd-acd1-35630b468c27',
|
||||
'recipe_yield': '',
|
||||
'slug': 'sweet-potatoes',
|
||||
@@ -841,6 +895,7 @@
|
||||
'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno',
|
||||
'perform_time': '50 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '40c227e0-3c7e-41f7-866d-5de04eaecdd7',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno',
|
||||
@@ -856,6 +911,7 @@
|
||||
'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx',
|
||||
'perform_time': '4 Hours',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'boeuf-bourguignon-la-vraie-recette-2',
|
||||
@@ -871,6 +927,7 @@
|
||||
'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx',
|
||||
'perform_time': '4 Hours',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': 'fc42c7d1-7b0f-4e04-b88a-dbd80b81540b',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'boeuf-bourguignon-la-vraie-recette-1',
|
||||
@@ -886,6 +943,7 @@
|
||||
'original_url': 'https://biancazapatka.com/de/erdnussbutter-schoko-bananenbrot/',
|
||||
'perform_time': '55 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '89e63d72-7a51-4cef-b162-2e45035d0a91',
|
||||
'recipe_yield': '14 servings',
|
||||
'slug': 'veganes-marmor-bananenbrot-mit-erdnussbutter',
|
||||
@@ -901,6 +959,7 @@
|
||||
'original_url': 'https://kuechenchaotin.de/pasta-mit-tomaten-knoblauch-basilikum/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'eab64457-97ba-4d6c-871c-cb1c724ccb51',
|
||||
'recipe_yield': '',
|
||||
'slug': 'pasta-mit-tomaten-knoblauch-und-basilikum-einfach-und-genial-kuechenchaotin',
|
||||
@@ -916,6 +975,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '12439e3d-3c1c-4dcc-9c6e-4afcea2a0542',
|
||||
'recipe_yield': None,
|
||||
'slug': 'test123',
|
||||
@@ -931,6 +991,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '6567f6ec-e410-49cb-a1a5-d08517184e78',
|
||||
'recipe_yield': None,
|
||||
'slug': 'bureeto',
|
||||
@@ -946,6 +1007,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'f7737d17-161c-4008-88d4-dd2616778cd0',
|
||||
'recipe_yield': None,
|
||||
'slug': 'subway-double-cookies',
|
||||
@@ -961,6 +1023,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '1904b717-4a8b-4de9-8909-56958875b5f4',
|
||||
'recipe_yield': None,
|
||||
'slug': 'qwerty12345',
|
||||
@@ -976,6 +1039,7 @@
|
||||
'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/',
|
||||
'perform_time': '22 Minutes',
|
||||
'prep_time': '8 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22',
|
||||
'recipe_yield': '24 servings',
|
||||
'slug': 'cheeseburger-sliders-easy-30-min-recipe',
|
||||
@@ -991,6 +1055,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '8a30d31d-aa14-411e-af0c-6b61a94f5291',
|
||||
'recipe_yield': '4',
|
||||
'slug': 'meatloaf',
|
||||
@@ -1006,6 +1071,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/937641199437984/Richtig-rheinischer-Sauerbraten.html',
|
||||
'perform_time': '2 Hours 20 Minutes',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': 3.0,
|
||||
'recipe_id': 'f2f7880b-1136-436f-91b7-129788d8c117',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'richtig-rheinischer-sauerbraten',
|
||||
@@ -1021,6 +1087,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/2307761368177614/Orientalischer-Gemuese-Haehnchen-Eintopf.html',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'cf634591-0f82-4254-8e00-2f7e8b0c9022',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'orientalischer-gemuse-hahnchen-eintopf',
|
||||
@@ -1036,6 +1103,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '05208856-d273-4cc9-bcfa-e0215d57108d',
|
||||
'recipe_yield': '4',
|
||||
'slug': 'test-20240121',
|
||||
@@ -1051,6 +1119,7 @@
|
||||
'original_url': 'https://www.lekkerensimpel.com/loempia-bowl/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '145eeb05-781a-4eb0-a656-afa8bc8c0164',
|
||||
'recipe_yield': '',
|
||||
'slug': 'loempia-bowl',
|
||||
@@ -1066,6 +1135,7 @@
|
||||
'original_url': 'https://thehappypear.ie/aquafaba-chocolate-mousse/',
|
||||
'perform_time': None,
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '5c6532aa-ad84-424c-bc05-c32d50430fe4',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': '5-ingredient-chocolate-mousse',
|
||||
@@ -1081,6 +1151,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/1208161226570428/Der-perfekte-Pfannkuchen-gelingt-einfach-immer.html',
|
||||
'perform_time': '10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'f2e684f2-49e0-45ee-90de-951344472f1c',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'der-perfekte-pfannkuchen-gelingt-einfach-immer',
|
||||
@@ -1096,6 +1167,7 @@
|
||||
'original_url': 'https://www.besondersgut.ch/dinkel-sauerteigbrot/',
|
||||
'perform_time': '35min',
|
||||
'prep_time': '1h',
|
||||
'rating': None,
|
||||
'recipe_id': 'cf239441-b75d-4dea-a48e-9d99b7cb5842',
|
||||
'recipe_yield': '1',
|
||||
'slug': 'dinkel-sauerteigbrot',
|
||||
@@ -1111,6 +1183,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '2673eb90-6d78-4b95-af36-5db8c8a6da37',
|
||||
'recipe_yield': None,
|
||||
'slug': 'test-234234',
|
||||
@@ -1126,6 +1199,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '0a723c54-af53-40e9-a15f-c87aae5ac688',
|
||||
'recipe_yield': None,
|
||||
'slug': 'test-243',
|
||||
@@ -1141,6 +1215,7 @@
|
||||
'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'einfacher-nudelauflauf-mit-brokkoli',
|
||||
@@ -1167,6 +1242,7 @@
|
||||
'original_url': 'https://www.przepisy.pl/przepis/tarta-cytrynowa-z-beza',
|
||||
'perform_time': None,
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': '9d3cb303-a996-4144-948a-36afaeeef554',
|
||||
'recipe_yield': '8 servings',
|
||||
'slug': 'tarta-cytrynowa-z-beza',
|
||||
@@ -1182,6 +1258,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '77f05a49-e869-4048-aa62-0d8a1f5a8f1c',
|
||||
'recipe_yield': None,
|
||||
'slug': 'martins-test-recipe',
|
||||
@@ -1197,6 +1274,7 @@
|
||||
'original_url': 'https://aniagotuje.pl/przepis/muffinki-czekoladowe',
|
||||
'perform_time': '30 Minutes',
|
||||
'prep_time': '25 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '75a90207-9c10-4390-a265-c47a4b67fd69',
|
||||
'recipe_yield': '12',
|
||||
'slug': 'muffinki-czekoladowe',
|
||||
@@ -1212,6 +1290,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '4320ba72-377b-4657-8297-dce198f24cdf',
|
||||
'recipe_yield': None,
|
||||
'slug': 'my-test-recipe',
|
||||
@@ -1227,6 +1306,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '98dac844-31ee-426a-b16c-fb62a5dd2816',
|
||||
'recipe_yield': None,
|
||||
'slug': 'my-test-receipe',
|
||||
@@ -1242,6 +1322,7 @@
|
||||
'original_url': 'https://www.papillesetpupilles.fr/2018/10/patates-douces-au-four.html/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c3c8f207-c704-415d-81b1-da9f032cf52f',
|
||||
'recipe_yield': '',
|
||||
'slug': 'patates-douces-au-four',
|
||||
@@ -1257,6 +1338,7 @@
|
||||
'original_url': 'https://sallysbakingaddiction.com/homemade-pizza-crust-recipe/',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '2 Hours 15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '1edb2f6e-133c-4be0-b516-3c23625a97ec',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'easy-homemade-pizza-dough',
|
||||
@@ -1272,6 +1354,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe',
|
||||
'perform_time': '3 Hours 10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'all-american-beef-stew-recipe',
|
||||
@@ -1287,6 +1370,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/serious-eats-halal-cart-style-chicken-and-rice-white-sauce-recipe',
|
||||
'perform_time': '55 Minutes',
|
||||
'prep_time': '20 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '6530ea6e-401e-4304-8a7a-12162ddf5b9c',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'serious-eats-halal-cart-style-chicken-and-rice-with-white-sauce',
|
||||
@@ -1302,6 +1386,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/1062121211526182/Schnelle-Kaesespaetzle.html',
|
||||
'perform_time': '30 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'c496cf9c-1ece-448a-9d3f-ef772f078a4e',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'schnelle-kasespatzle',
|
||||
@@ -1317,6 +1402,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '49aa6f42-6760-4adf-b6cd-59592da485c3',
|
||||
'recipe_yield': None,
|
||||
'slug': 'taco',
|
||||
@@ -1332,6 +1418,7 @@
|
||||
'original_url': 'https://www.ica.se/recept/vodkapasta-729011/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '6402a253-2baa-460d-bf4f-b759bb655588',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'vodkapasta',
|
||||
@@ -1347,6 +1434,7 @@
|
||||
'original_url': 'https://www.ica.se/recept/vodkapasta-729011/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '4f54e9e1-f21d-40ec-a135-91e633dfb733',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'vodkapasta2',
|
||||
@@ -1362,6 +1450,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'e1a3edb0-49a0-49a3-83e3-95554e932670',
|
||||
'recipe_yield': '1',
|
||||
'slug': 'rub',
|
||||
@@ -1377,6 +1466,7 @@
|
||||
'original_url': 'https://www.justapinch.com/recipes/dessert/cookies/banana-bread-chocolate-chip-cookies.html',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '1a0f4e54-db5b-40f1-ab7e-166dab5f6523',
|
||||
'recipe_yield': '',
|
||||
'slug': 'banana-bread-chocolate-chip-cookies',
|
||||
@@ -1392,6 +1482,7 @@
|
||||
'original_url': 'https://chefjeanpierre.com/recipes/soups/creamy-cauliflower-bisque/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '447acae6-3424-4c16-8c26-c09040ad8041',
|
||||
'recipe_yield': '',
|
||||
'slug': 'cauliflower-bisque-recipe-with-cheddar-cheese',
|
||||
@@ -1407,6 +1498,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': '864136a3-27b0-4f3b-a90f-486f42d6df7a',
|
||||
'recipe_yield': '',
|
||||
'slug': 'prova',
|
||||
@@ -1422,6 +1514,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c7ccf4c7-c5f4-4191-a79b-1a49d068f6a4',
|
||||
'recipe_yield': None,
|
||||
'slug': 'pate-au-beurre-1',
|
||||
@@ -1437,6 +1530,7 @@
|
||||
'original_url': None,
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'd01865c3-0f18-4e8d-84c0-c14c345fdf9c',
|
||||
'recipe_yield': None,
|
||||
'slug': 'pate-au-beurre',
|
||||
@@ -1452,6 +1546,7 @@
|
||||
'original_url': 'https://saltpepperskillet.com/recipes/sous-vide-cheesecake/',
|
||||
'perform_time': '1 Hour 30 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '2cec2bb2-19b6-40b8-a36c-1a76ea29c517',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'sous-vide-cheesecake-recipe',
|
||||
@@ -1467,6 +1562,7 @@
|
||||
'original_url': 'https://recipes.anovaculinary.com/recipe/the-bomb-cheesecakes',
|
||||
'perform_time': None,
|
||||
'prep_time': '30 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '8e0e4566-9caf-4c2e-a01c-dcead23db86b',
|
||||
'recipe_yield': '10 servings',
|
||||
'slug': 'the-bomb-mini-cheesecakes',
|
||||
@@ -1482,6 +1578,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/2109501340136606/Tagliatelle-al-Salmone.html',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'a051eafd-9712-4aee-a8e5-0cd10a6772ee',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'tagliatelle-al-salmone',
|
||||
@@ -1497,6 +1594,7 @@
|
||||
'original_url': 'https://www.backenmachtgluecklich.de/rezepte/death-by-chocolate-kuchen.html',
|
||||
'perform_time': '25 Minutes',
|
||||
'prep_time': '25 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '093d51e9-0823-40ad-8e0e-a1d5790dd627',
|
||||
'recipe_yield': '1 serving',
|
||||
'slug': 'death-by-chocolate',
|
||||
@@ -1512,6 +1610,7 @@
|
||||
'original_url': 'https://www.fernweh-koch.de/palak-dal-indischer-spinat-linsen-rezept/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '2d1f62ec-4200-4cfd-987e-c75755d7607c',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'palak-dal-rezept-aus-indien',
|
||||
@@ -1527,6 +1626,7 @@
|
||||
'original_url': 'https://www.chefkoch.de/rezepte/74441028021809/Tortelline-a-la-Romana.html',
|
||||
'perform_time': None,
|
||||
'prep_time': '30 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '973dc36d-1661-49b4-ad2d-0b7191034fb3',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'tortelline-a-la-romana',
|
||||
@@ -1686,6 +1786,7 @@
|
||||
'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/',
|
||||
'perform_time': '1 hour',
|
||||
'prep_time': '1 hour 30 minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'original-sacher-torte-2',
|
||||
@@ -1750,6 +1851,7 @@
|
||||
'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'zoete-aardappel-curry-traybake',
|
||||
@@ -1775,6 +1877,7 @@
|
||||
'original_url': 'https://tastesbetterfromscratch.com/roast-chicken/',
|
||||
'perform_time': '1 Hour 20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '5b055066-d57d-4fd0-8dfd-a2c2f07b36f1',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'roast-chicken',
|
||||
@@ -1800,6 +1903,7 @@
|
||||
'original_url': 'https://www.directoalpaladar.com/recetas-de-carnes-y-aves/receta-de-pollo-al-curry-en-10-minutos',
|
||||
'perform_time': '7 Minutes',
|
||||
'prep_time': '3 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'e360a0cc-18b0-4a84-a91b-8aa59e2451c9',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'receta-de-pollo-al-curry-en-10-minutos-con-video-incluido',
|
||||
@@ -1825,6 +1929,7 @@
|
||||
'original_url': 'https://www.marmiton.org/recettes/recette_boeuf-bourguignon_18889.aspx',
|
||||
'perform_time': '4 Hours',
|
||||
'prep_time': '1 Hour',
|
||||
'rating': None,
|
||||
'recipe_id': '9c7b8aee-c93c-4b1b-ab48-2625d444743a',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'boeuf-bourguignon-la-vraie-recette-2',
|
||||
@@ -1850,6 +1955,7 @@
|
||||
'original_url': 'https://akispetretzikis.com/recipe/7959/efkolh-makaronada-me-keftedakia-ston-fourno',
|
||||
'perform_time': '50 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'f79f7e9d-4b58-4930-a586-2b127f16ee34',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'eukole-makaronada-me-kephtedakia-ston-phourno-1',
|
||||
@@ -1875,6 +1981,7 @@
|
||||
'original_url': 'https://www.ambitiouskitchen.com/greek-turkey-meatballs/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '40 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '47595e4c-52bc-441d-b273-3edf4258806d',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'greek-turkey-meatballs-with-lemon-orzo-creamy-feta-yogurt-sauce',
|
||||
@@ -1900,6 +2007,7 @@
|
||||
'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'einfacher-nudelauflauf-mit-brokkoli',
|
||||
@@ -1925,6 +2033,7 @@
|
||||
'original_url': 'https://www.food.com/recipe/pampered-chef-double-chocolate-mocha-trifle-74963',
|
||||
'perform_time': '1 Hour',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': 3.0,
|
||||
'recipe_id': '92635fd0-f2dc-4e78-a6e4-ecd556ad361f',
|
||||
'recipe_yield': '12 servings',
|
||||
'slug': 'pampered-chef-double-chocolate-mocha-trifle',
|
||||
@@ -1950,6 +2059,7 @@
|
||||
'original_url': 'https://natashaskitchen.com/cheeseburger-sliders/',
|
||||
'perform_time': '22 Minutes',
|
||||
'prep_time': '8 Minutes',
|
||||
'rating': 5.0,
|
||||
'recipe_id': '8bdd3656-5e7e-45d3-a3c4-557390846a22',
|
||||
'recipe_yield': '24 servings',
|
||||
'slug': 'cheeseburger-sliders-easy-30-min-recipe',
|
||||
@@ -1975,6 +2085,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe',
|
||||
'perform_time': '3 Hours 10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'all-american-beef-stew-recipe',
|
||||
@@ -2000,6 +2111,7 @@
|
||||
'original_url': 'https://www.seriouseats.com/all-american-beef-stew-recipe',
|
||||
'perform_time': '3 Hours 10 Minutes',
|
||||
'prep_time': '5 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '48f39d27-4b8e-4c14-bf36-4e1e6497e75e',
|
||||
'recipe_yield': '6 servings',
|
||||
'slug': 'all-american-beef-stew-recipe',
|
||||
@@ -2025,6 +2137,7 @@
|
||||
'original_url': 'https://kochkarussell.com/einfacher-nudelauflauf-brokkoli/',
|
||||
'perform_time': '20 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '9d553779-607e-471b-acf3-84e6be27b159',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'einfacher-nudelauflauf-mit-brokkoli',
|
||||
@@ -2050,6 +2163,7 @@
|
||||
'original_url': 'https://www.allrecipes.com/recipe/284039/miso-udon-noodles-with-spinach-and-tofu/',
|
||||
'perform_time': '15 Minutes',
|
||||
'prep_time': '10 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '25b814f2-d9bf-4df0-b40d-d2f2457b4317',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'miso-udon-noodles-with-spinach-and-tofu',
|
||||
@@ -2075,6 +2189,7 @@
|
||||
'original_url': 'https://www.ricardocuisine.com/recettes/8919-mousse-de-saumon',
|
||||
'perform_time': '2 Minutes',
|
||||
'prep_time': '15 Minutes',
|
||||
'rating': None,
|
||||
'recipe_id': '55c88810-4cf1-4d86-ae50-63b15fd173fb',
|
||||
'recipe_yield': '12 servings',
|
||||
'slug': 'mousse-de-saumon',
|
||||
@@ -2247,6 +2362,7 @@
|
||||
'original_url': 'https://www.sacher.com/en/original-sacher-torte/recipe/',
|
||||
'perform_time': '1 hour',
|
||||
'prep_time': '1 hour 30 minutes',
|
||||
'rating': None,
|
||||
'recipe_id': 'fada9582-709b-46aa-b384-d5952123ad93',
|
||||
'recipe_yield': '4 servings',
|
||||
'slug': 'original-sacher-torte-2',
|
||||
@@ -2310,6 +2426,7 @@
|
||||
'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'zoete-aardappel-curry-traybake',
|
||||
@@ -2339,6 +2456,7 @@
|
||||
'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'zoete-aardappel-curry-traybake',
|
||||
@@ -2368,6 +2486,7 @@
|
||||
'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'zoete-aardappel-curry-traybake',
|
||||
@@ -2397,6 +2516,7 @@
|
||||
'original_url': 'https://chickslovefood.com/recept/zoete-aardappel-curry-traybake/',
|
||||
'perform_time': None,
|
||||
'prep_time': None,
|
||||
'rating': None,
|
||||
'recipe_id': 'c5f00a93-71a2-4e48-900f-d9ad0bb9de93',
|
||||
'recipe_yield': '2 servings',
|
||||
'slug': 'zoete-aardappel-curry-traybake',
|
||||
|
||||
@@ -25,6 +25,32 @@
|
||||
"dhw_cm_switch": false
|
||||
}
|
||||
},
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Emma Pro",
|
||||
"model_id": "170-01",
|
||||
"name": "Emma",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.0,
|
||||
"setpoint": 23.5,
|
||||
"temperature": 24.2
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.0,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "60EFABFFFE89CBA0"
|
||||
},
|
||||
"1772a4ea304041adb83f357b751341ff": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
@@ -38,11 +64,11 @@
|
||||
"model_id": "106-03",
|
||||
"name": "Tom Badkamer",
|
||||
"sensors": {
|
||||
"battery": 99,
|
||||
"setpoint": 18.0,
|
||||
"temperature": 21.6,
|
||||
"temperature_difference": -0.2,
|
||||
"valve_position": 100
|
||||
"battery": 60,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 24.8,
|
||||
"temperature_difference": -0.4,
|
||||
"valve_position": 100.0
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
@@ -51,10 +77,9 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C8FF5EE"
|
||||
"zigbee_mac_address": "000D6F000C8FCBA0"
|
||||
},
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
|
||||
"available": true,
|
||||
"dev_class": "thermostat",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "ThermoTouch",
|
||||
@@ -62,7 +87,7 @@
|
||||
"name": "Anna",
|
||||
"sensors": {
|
||||
"setpoint": 23.5,
|
||||
"temperature": 25.8
|
||||
"temperature": 24.0
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
@@ -71,20 +96,20 @@
|
||||
"plugwise_notification": false
|
||||
},
|
||||
"dev_class": "gateway",
|
||||
"firmware": "3.7.8",
|
||||
"firmware": "3.9.0",
|
||||
"gateway_modes": ["away", "full", "vacation"],
|
||||
"hardware": "AME Smile 2.0 board",
|
||||
"location": "bc93488efab249e5bc54fd7e175a6f91",
|
||||
"mac_address": "012345679891",
|
||||
"mac_address": "D40FB201CBA0",
|
||||
"model": "Gateway",
|
||||
"model_id": "smile_open_therm",
|
||||
"name": "Adam",
|
||||
"notifications": {},
|
||||
"regulation_modes": [
|
||||
"bleeding_hot",
|
||||
"bleeding_cold",
|
||||
"off",
|
||||
"heating",
|
||||
"off",
|
||||
"bleeding_hot",
|
||||
"cooling"
|
||||
],
|
||||
"select_gateway_mode": "full",
|
||||
@@ -93,12 +118,33 @@
|
||||
"outdoor_temperature": 29.65
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000D5A168D"
|
||||
"zigbee_mac_address": "000D6F000D5ACBA0"
|
||||
},
|
||||
"da575e9e09b947e281fb6e3ebce3b174": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermometer",
|
||||
"firmware": "2020-09-01T02:00:00+02:00",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Jip",
|
||||
"model_id": "168-01",
|
||||
"name": "Jip",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.8,
|
||||
"setpoint": 23.5,
|
||||
"temperature": 23.8
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "70AC08FFFEE1CBA0"
|
||||
},
|
||||
"e2f4322d57924fa090fbbc48b3a140dc": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": true
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"firmware": "2016-10-10T02:00:00+02:00",
|
||||
@@ -108,9 +154,9 @@
|
||||
"model_id": "158-01",
|
||||
"name": "Lisa Badkamer",
|
||||
"sensors": {
|
||||
"battery": 14,
|
||||
"setpoint": 23.5,
|
||||
"temperature": 23.9
|
||||
"battery": 71,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 25.6
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
@@ -119,7 +165,7 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C869B61"
|
||||
"zigbee_mac_address": "000D6F000C86CBA0"
|
||||
},
|
||||
"e8ef2a01ed3b4139a53bf749204fe6b4": {
|
||||
"dev_class": "switching",
|
||||
@@ -138,9 +184,9 @@
|
||||
"active_preset": "home",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "cool",
|
||||
@@ -148,12 +194,12 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Living room",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 149.9,
|
||||
"electricity_consumed": 60.8,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 25.8
|
||||
"temperature": 24.2
|
||||
},
|
||||
"thermostat": {
|
||||
"lower_bound": 1.0,
|
||||
@@ -162,18 +208,22 @@
|
||||
"upper_bound": 35.0
|
||||
},
|
||||
"thermostats": {
|
||||
"primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"],
|
||||
"primary": [
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8",
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6",
|
||||
"da575e9e09b947e281fb6e3ebce3b174"
|
||||
],
|
||||
"secondary": []
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"f871b8c4d63549319221e294e4f88074": {
|
||||
"active_preset": "home",
|
||||
"active_preset": "vacation",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "auto",
|
||||
@@ -181,12 +231,12 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Bathroom",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"select_schedule": "Badkamer",
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 0.0,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 23.9
|
||||
"temperature": 25.8
|
||||
},
|
||||
"thermostat": {
|
||||
"lower_bound": 0.0,
|
||||
|
||||
@@ -30,6 +30,32 @@
|
||||
"dhw_cm_switch": false
|
||||
}
|
||||
},
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Emma Pro",
|
||||
"model_id": "170-01",
|
||||
"name": "Emma",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.0,
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.5
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
"resolution": 0.1,
|
||||
"setpoint": 0.0,
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "60EFABFFFE89CBA0"
|
||||
},
|
||||
"1772a4ea304041adb83f357b751341ff": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
@@ -43,11 +69,11 @@
|
||||
"model_id": "106-03",
|
||||
"name": "Tom Badkamer",
|
||||
"sensors": {
|
||||
"battery": 99,
|
||||
"setpoint": 18.0,
|
||||
"battery": 60,
|
||||
"setpoint": 25.0,
|
||||
"temperature": 18.6,
|
||||
"temperature_difference": -0.2,
|
||||
"valve_position": 100
|
||||
"temperature_difference": -0.4,
|
||||
"valve_position": 100.0
|
||||
},
|
||||
"temperature_offset": {
|
||||
"lower_bound": -2.0,
|
||||
@@ -56,10 +82,9 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C8FF5EE"
|
||||
"zigbee_mac_address": "000D6F000C8FCBA0"
|
||||
},
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8": {
|
||||
"available": true,
|
||||
"dev_class": "thermostat",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "ThermoTouch",
|
||||
@@ -76,28 +101,49 @@
|
||||
"plugwise_notification": false
|
||||
},
|
||||
"dev_class": "gateway",
|
||||
"firmware": "3.7.8",
|
||||
"firmware": "3.9.0",
|
||||
"gateway_modes": ["away", "full", "vacation"],
|
||||
"hardware": "AME Smile 2.0 board",
|
||||
"location": "bc93488efab249e5bc54fd7e175a6f91",
|
||||
"mac_address": "012345679891",
|
||||
"mac_address": "D40FB201CBA0",
|
||||
"model": "Gateway",
|
||||
"model_id": "smile_open_therm",
|
||||
"name": "Adam",
|
||||
"notifications": {},
|
||||
"regulation_modes": ["bleeding_hot", "bleeding_cold", "off", "heating"],
|
||||
"regulation_modes": ["bleeding_cold", "heating", "off", "bleeding_hot"],
|
||||
"select_gateway_mode": "full",
|
||||
"select_regulation_mode": "heating",
|
||||
"sensors": {
|
||||
"outdoor_temperature": -1.25
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000D5A168D"
|
||||
"zigbee_mac_address": "000D6F000D5ACBA0"
|
||||
},
|
||||
"da575e9e09b947e281fb6e3ebce3b174": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermometer",
|
||||
"firmware": "2020-09-01T02:00:00+02:00",
|
||||
"hardware": "1",
|
||||
"location": "f2bf9048bef64cc5b6d5110154e33c81",
|
||||
"model": "Jip",
|
||||
"model_id": "168-01",
|
||||
"name": "Jip",
|
||||
"sensors": {
|
||||
"battery": 100,
|
||||
"humidity": 65.8,
|
||||
"setpoint": 20.0,
|
||||
"temperature": 19.3
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "70AC08FFFEE1CBA0"
|
||||
},
|
||||
"e2f4322d57924fa090fbbc48b3a140dc": {
|
||||
"available": true,
|
||||
"binary_sensors": {
|
||||
"low_battery": true
|
||||
"low_battery": false
|
||||
},
|
||||
"dev_class": "zone_thermostat",
|
||||
"firmware": "2016-10-10T02:00:00+02:00",
|
||||
@@ -107,7 +153,7 @@
|
||||
"model_id": "158-01",
|
||||
"name": "Lisa Badkamer",
|
||||
"sensors": {
|
||||
"battery": 14,
|
||||
"battery": 71,
|
||||
"setpoint": 15.0,
|
||||
"temperature": 17.9
|
||||
},
|
||||
@@ -118,7 +164,7 @@
|
||||
"upper_bound": 2.0
|
||||
},
|
||||
"vendor": "Plugwise",
|
||||
"zigbee_mac_address": "000D6F000C869B61"
|
||||
"zigbee_mac_address": "000D6F000C86CBA0"
|
||||
},
|
||||
"e8ef2a01ed3b4139a53bf749204fe6b4": {
|
||||
"dev_class": "switching",
|
||||
@@ -137,9 +183,9 @@
|
||||
"active_preset": "home",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "heat",
|
||||
@@ -147,10 +193,10 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Living room",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 149.9,
|
||||
"electricity_consumed": 60.8,
|
||||
"electricity_produced": 0.0,
|
||||
"temperature": 19.1
|
||||
},
|
||||
@@ -161,18 +207,22 @@
|
||||
"upper_bound": 35.0
|
||||
},
|
||||
"thermostats": {
|
||||
"primary": ["ad4838d7d35c4d6ea796ee12ae5aedf8"],
|
||||
"primary": [
|
||||
"ad4838d7d35c4d6ea796ee12ae5aedf8",
|
||||
"14df5c4dc8cb4ba69f9d1ac0eaf7c5c6",
|
||||
"da575e9e09b947e281fb6e3ebce3b174"
|
||||
],
|
||||
"secondary": []
|
||||
},
|
||||
"vendor": "Plugwise"
|
||||
},
|
||||
"f871b8c4d63549319221e294e4f88074": {
|
||||
"active_preset": "home",
|
||||
"active_preset": "vacation",
|
||||
"available_schedules": [
|
||||
"Badkamer",
|
||||
"Test",
|
||||
"Vakantie",
|
||||
"Weekschema",
|
||||
"Test",
|
||||
"off"
|
||||
],
|
||||
"climate_mode": "auto",
|
||||
@@ -180,8 +230,8 @@
|
||||
"dev_class": "climate",
|
||||
"model": "ThermoZone",
|
||||
"name": "Bathroom",
|
||||
"preset_modes": ["no_frost", "asleep", "vacation", "home", "away"],
|
||||
"select_schedule": "Badkamer",
|
||||
"preset_modes": ["vacation", "no_frost", "asleep", "home", "away"],
|
||||
"select_schedule": "off",
|
||||
"sensors": {
|
||||
"electricity_consumed": 0.0,
|
||||
"electricity_produced": 0.0,
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 0.0,
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
@@ -63,11 +63,11 @@
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 0.0,
|
||||
'preset_mode': 'home',
|
||||
'preset_mode': 'vacation',
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
@@ -97,9 +97,9 @@
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 1.0,
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
@@ -149,9 +149,9 @@
|
||||
'min_temp': 1.0,
|
||||
'preset_mode': 'home',
|
||||
'preset_modes': list([
|
||||
'vacation',
|
||||
'no_frost',
|
||||
'asleep',
|
||||
'vacation',
|
||||
'home',
|
||||
'away',
|
||||
]),
|
||||
|
||||
@@ -65,10 +65,10 @@
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'bleeding_hot',
|
||||
'bleeding_cold',
|
||||
'off',
|
||||
'heating',
|
||||
'off',
|
||||
'bleeding_hot',
|
||||
'cooling',
|
||||
]),
|
||||
}),
|
||||
@@ -106,10 +106,10 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Adam Regulation mode',
|
||||
'options': list([
|
||||
'bleeding_hot',
|
||||
'bleeding_cold',
|
||||
'off',
|
||||
'heating',
|
||||
'off',
|
||||
'bleeding_hot',
|
||||
'cooling',
|
||||
]),
|
||||
}),
|
||||
@@ -129,9 +129,9 @@
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
@@ -170,9 +170,9 @@
|
||||
'friendly_name': 'Bathroom Thermostat schedule',
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
@@ -181,7 +181,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'Badkamer',
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_adam_2_select_entities[platforms0-True-m_adam_cooling][select.living_room_thermostat_schedule-entry]
|
||||
@@ -192,9 +192,9 @@
|
||||
'capabilities': dict({
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
@@ -233,9 +233,9 @@
|
||||
'friendly_name': 'Living room Thermostat schedule',
|
||||
'options': list([
|
||||
'Badkamer',
|
||||
'Test',
|
||||
'Vakantie',
|
||||
'Weekschema',
|
||||
'Test',
|
||||
'off',
|
||||
]),
|
||||
}),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,7 +257,7 @@ async def test_update_device(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 38
|
||||
== 49
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
@@ -265,7 +265,7 @@ async def test_update_device(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 8
|
||||
== 10
|
||||
)
|
||||
|
||||
# Add a 2nd Tom/Floor
|
||||
@@ -289,7 +289,7 @@ async def test_update_device(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 45
|
||||
== 56
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
@@ -297,7 +297,7 @@ async def test_update_device(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 9
|
||||
== 11
|
||||
)
|
||||
item_list: list[str] = []
|
||||
for device_entry in list(device_registry.devices.values()):
|
||||
@@ -320,7 +320,7 @@ async def test_update_device(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 38
|
||||
== 49
|
||||
)
|
||||
assert (
|
||||
len(
|
||||
@@ -328,7 +328,7 @@ async def test_update_device(
|
||||
device_registry, mock_config_entry.entry_id
|
||||
)
|
||||
)
|
||||
== 8
|
||||
== 10
|
||||
)
|
||||
item_list: list[str] = []
|
||||
for device_entry in list(device_registry.devices.values()):
|
||||
|
||||
@@ -13,11 +13,13 @@ from homeassistant.helpers import entity_registry as er
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.parametrize("chosen_env", ["m_adam_heating"], indirect=True)
|
||||
@pytest.mark.parametrize("cooling_present", [False], indirect=True)
|
||||
@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)])
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_adam_sensor_snapshot(
|
||||
hass: HomeAssistant,
|
||||
mock_smile_adam: MagicMock,
|
||||
mock_smile_adam_heat_cool: MagicMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
entity_registry: er.EntityRegistry,
|
||||
setup_platform: MockConfigEntry,
|
||||
|
||||
161
tests/components/satel_integra/snapshots/test_binary_sensor.ambr
Normal file
161
tests/components/satel_integra/snapshots/test_binary_sensor.ambr
Normal file
@@ -0,0 +1,161 @@
|
||||
# serializer version: 1
|
||||
# name: test_binary_sensors[binary_sensor.output-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.output',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.SAFETY: 'safety'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'satel_integra',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '1234567890_outputs_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[binary_sensor.output-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'safety',
|
||||
'friendly_name': 'Output',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.output',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[binary_sensor.zone-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'binary_sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'binary_sensor.zone',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <BinarySensorDeviceClass.MOTION: 'motion'>,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'satel_integra',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '1234567890_zones_1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[binary_sensor.zone-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'motion',
|
||||
'friendly_name': 'Zone',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'binary_sensor.zone',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[device-output]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'satel_integra',
|
||||
'1234567890_outputs_1',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': None,
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'Output',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_binary_sensors[device-zone]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'config_entries_subentries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'satel_integra',
|
||||
'1234567890_zones_1',
|
||||
),
|
||||
}),
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': None,
|
||||
'model': None,
|
||||
'model_id': None,
|
||||
'name': 'Zone',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
102
tests/components/satel_integra/test_binary_sensor.py
Normal file
102
tests/components/satel_integra/test_binary_sensor.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Test Satel Integra Binary Sensor."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.binary_sensor import STATE_OFF, STATE_ON
|
||||
from homeassistant.components.satel_integra.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceRegistry
|
||||
from homeassistant.helpers.entity_registry import EntityRegistry
|
||||
|
||||
from . import setup_integration
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def binary_sensor_only() -> AsyncGenerator[None]:
|
||||
"""Enable only the binary sensor platform."""
|
||||
with patch(
|
||||
"homeassistant.components.satel_integra.PLATFORMS",
|
||||
[Platform.BINARY_SENSOR],
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_satel")
|
||||
async def test_binary_sensors(
|
||||
hass: HomeAssistant,
|
||||
snapshot: SnapshotAssertion,
|
||||
mock_config_entry_with_subentries: MockConfigEntry,
|
||||
entity_registry: EntityRegistry,
|
||||
device_registry: DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test binary sensors correctly being set up."""
|
||||
await setup_integration(hass, mock_config_entry_with_subentries)
|
||||
|
||||
assert mock_config_entry_with_subentries.state is ConfigEntryState.LOADED
|
||||
|
||||
await snapshot_platform(
|
||||
hass, entity_registry, snapshot, mock_config_entry_with_subentries.entry_id
|
||||
)
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "1234567890_zones_1")}
|
||||
)
|
||||
|
||||
assert device_entry == snapshot(name="device-zone")
|
||||
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, "1234567890_outputs_1")}
|
||||
)
|
||||
assert device_entry == snapshot(name="device-output")
|
||||
|
||||
|
||||
async def test_binary_sensor_initial_state_on(
|
||||
hass: HomeAssistant,
|
||||
mock_satel: AsyncMock,
|
||||
mock_config_entry_with_subentries: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test binary sensors have a correct initial state ON after initialization."""
|
||||
mock_satel.violated_zones = [1]
|
||||
mock_satel.violated_outputs = [1]
|
||||
|
||||
await setup_integration(hass, mock_config_entry_with_subentries)
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_ON
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_ON
|
||||
|
||||
|
||||
async def test_binary_sensor_callback(
|
||||
hass: HomeAssistant,
|
||||
mock_satel: AsyncMock,
|
||||
mock_config_entry_with_subentries: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test binary sensors correctly change state after a callback from the panel."""
|
||||
await setup_integration(hass, mock_config_entry_with_subentries)
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_OFF
|
||||
|
||||
monitor_status_call = mock_satel.monitor_status.call_args_list[0][0]
|
||||
output_update_method = monitor_status_call[2]
|
||||
zone_update_method = monitor_status_call[1]
|
||||
|
||||
# Should do nothing, only react to it's own number
|
||||
output_update_method({"outputs": {2: 1}})
|
||||
zone_update_method({"zones": {2: 1}})
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_OFF
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_OFF
|
||||
|
||||
output_update_method({"outputs": {1: 1}})
|
||||
zone_update_method({"zones": {1: 1}})
|
||||
|
||||
assert hass.states.get("binary_sensor.zone").state == STATE_ON
|
||||
assert hass.states.get("binary_sensor.output").state == STATE_ON
|
||||
@@ -1,4 +1,196 @@
|
||||
# serializer version: 1
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_factory_reset-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.kattenbak_factory_reset',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Factory reset',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'factory_reset',
|
||||
'unique_id': 'tuya.yohkwjjdjlzludd3psmfactory_reset',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_factory_reset-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Kattenbak Factory reset',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.kattenbak_factory_reset',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_manual_clean-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.kattenbak_manual_clean',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Manual clean',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'manual_clean',
|
||||
'unique_id': 'tuya.yohkwjjdjlzludd3psmmanual_clean',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.kattenbak_manual_clean-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Kattenbak Manual clean',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.kattenbak_manual_clean',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_factory_reset-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.poopy_nano_2_factory_reset',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Factory reset',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'factory_reset',
|
||||
'unique_id': 'tuya.nyriu7sjgj9oruzmpsmfactory_reset',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_factory_reset-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Poopy Nano 2 Factory reset',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.poopy_nano_2_factory_reset',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_manual_clean-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'button.poopy_nano_2_manual_clean',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Manual clean',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'manual_clean',
|
||||
'unique_id': 'tuya.nyriu7sjgj9oruzmpsmmanual_clean',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.poopy_nano_2_manual_clean-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Poopy Nano 2 Manual clean',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.poopy_nano_2_manual_clean',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[button.v20_reset_duster_cloth-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
'event_type': None,
|
||||
'event_type': 'click',
|
||||
'event_types': list([
|
||||
'click',
|
||||
'press',
|
||||
@@ -55,7 +55,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
'state': '2023-11-01T12:14:15.000+00:00',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[event.bathroom_smart_switch_button_2-entry]
|
||||
@@ -102,7 +102,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
'event_type': None,
|
||||
'event_type': 'click',
|
||||
'event_types': list([
|
||||
'click',
|
||||
'press',
|
||||
@@ -114,7 +114,7 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
'state': '2023-11-01T12:14:15.000+00:00',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[event.bouton_tempo_exterieur_button_1-entry]
|
||||
@@ -162,7 +162,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'button',
|
||||
'event_type': None,
|
||||
'event_type': 'press',
|
||||
'event_types': list([
|
||||
'click',
|
||||
'double_click',
|
||||
@@ -175,6 +175,6 @@
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
'state': '2023-11-01T12:14:15.000+00:00',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -1827,6 +1827,63 @@
|
||||
'state': 'on',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.kattenbak_light-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'light',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'light.kattenbak_light',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Light',
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'light',
|
||||
'unique_id': 'tuya.yohkwjjdjlzludd3psmlight',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.kattenbak_light-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'color_mode': None,
|
||||
'friendly_name': 'Kattenbak Light',
|
||||
'supported_color_modes': list([
|
||||
<ColorMode.ONOFF: 'onoff'>,
|
||||
]),
|
||||
'supported_features': <LightEntityFeature: 0>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'light.kattenbak_light',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_platform_setup_and_discovery[light.landing-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -4,10 +4,12 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
|
||||
from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
@@ -17,6 +19,7 @@ from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON])
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
async def test_platform_setup_and_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
@@ -29,3 +32,33 @@ async def test_platform_setup_and_discovery(
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.BUTTON])
|
||||
@pytest.mark.parametrize(
|
||||
"mock_device_code",
|
||||
["sd_lr33znaodtyarrrz"],
|
||||
)
|
||||
async def test_button_press(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_device: CustomerDevice,
|
||||
) -> None:
|
||||
"""Test pressing a button."""
|
||||
entity_id = "button.v20_reset_duster_cloth"
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None, f"{entity_id} does not exist"
|
||||
await hass.services.async_call(
|
||||
BUTTON_DOMAIN,
|
||||
SERVICE_PRESS,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
mock_manager.send_commands.assert_called_once_with(
|
||||
mock_device.id, [{"code": "reset_duster_cloth", "value": True}]
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
from tuya_sharing import CustomerDevice, Manager
|
||||
|
||||
@@ -11,21 +12,29 @@ from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import initialize_entry
|
||||
from . import MockDeviceListener, initialize_entry
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
|
||||
|
||||
@pytest.mark.freeze_time("2023-11-01 13:14:15+01:00")
|
||||
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.EVENT])
|
||||
async def test_platform_setup_and_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_manager: Manager,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_devices: list[CustomerDevice],
|
||||
mock_listener: MockDeviceListener,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test platform setup and discovery."""
|
||||
await initialize_entry(hass, mock_manager, mock_config_entry, mock_devices)
|
||||
|
||||
for mock_device in mock_devices:
|
||||
# Simulate an initial device update to generate events
|
||||
await mock_listener.async_send_device_update(
|
||||
hass, mock_device, mock_device.status
|
||||
)
|
||||
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
@@ -7,7 +7,9 @@ import pytest
|
||||
|
||||
from homeassistant.components.velux import DOMAIN
|
||||
from homeassistant.components.velux.binary_sensor import Window
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD
|
||||
from homeassistant.components.velux.light import LighteningDevice
|
||||
from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
@@ -79,10 +81,20 @@ def mock_window() -> AsyncMock:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pyvlx(mock_window: MagicMock) -> Generator[MagicMock]:
|
||||
def mock_light() -> AsyncMock:
|
||||
"""Create a mock Velux light."""
|
||||
light = AsyncMock(spec=LighteningDevice, autospec=True)
|
||||
light.name = "Test Light"
|
||||
light.serial_number = "0815"
|
||||
light.intensity = MagicMock()
|
||||
return light
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pyvlx(mock_window: MagicMock, mock_light: MagicMock) -> Generator[MagicMock]:
|
||||
"""Create the library mock and patch PyVLX."""
|
||||
pyvlx = MagicMock()
|
||||
pyvlx.nodes = [mock_window]
|
||||
pyvlx.nodes = [mock_window, mock_light]
|
||||
pyvlx.load_scenes = AsyncMock()
|
||||
pyvlx.load_nodes = AsyncMock()
|
||||
pyvlx.disconnect = AsyncMock()
|
||||
@@ -101,3 +113,18 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
CONF_PASSWORD: "testpw",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_pyvlx: MagicMock,
|
||||
platform: Platform,
|
||||
) -> None:
|
||||
"""Set up the integration for testing."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.velux.PLATFORMS", [platform]):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
43
tests/components/velux/test_light.py
Normal file
43
tests/components/velux/test_light.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Test Velux light entities."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform() -> Platform:
|
||||
"""Fixture to specify platform to test."""
|
||||
return Platform.LIGHT
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("setup_integration")
|
||||
async def test_light_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_light: AsyncMock,
|
||||
) -> None:
|
||||
"""Test light entity setup and device association."""
|
||||
|
||||
test_entity_id = f"light.{mock_light.name.lower().replace(' ', '_')}"
|
||||
|
||||
# Check that the entity exists and its name matches the node name (the light is the main feature).
|
||||
state = hass.states.get(test_entity_id)
|
||||
assert state is not None
|
||||
assert state.attributes.get("friendly_name") == mock_light.name
|
||||
|
||||
# Get entity + device entry
|
||||
entity_entry = entity_registry.async_get(test_entity_id)
|
||||
assert entity_entry is not None
|
||||
assert entity_entry.device_id is not None
|
||||
device_entry = device_registry.async_get(entity_entry.device_id)
|
||||
assert device_entry is not None
|
||||
|
||||
# Verify device has correct identifiers + name
|
||||
assert ("velux", mock_light.serial_number) in device_entry.identifiers
|
||||
assert device_entry.name == mock_light.name
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import AsyncMock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from volvocarsapi.api import VolvoCarsApi
|
||||
@@ -13,6 +13,9 @@ from homeassistant.components.volvo.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
)
|
||||
|
||||
from . import configure_mock
|
||||
from .const import MOCK_ACCESS_TOKEN, SERVER_TOKEN_RESPONSE
|
||||
@@ -132,3 +135,20 @@ async def test_vehicle_auth_failure(
|
||||
configure_mock(mock_method, return_value=None, side_effect=VolvoAuthException())
|
||||
assert not await setup_integration()
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test that unavailable OAuth implementation raises ConfigEntryNotReady."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.volvo.async_get_config_entry_implementation",
|
||||
side_effect=ImplementationUnavailableError,
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
@@ -2878,6 +2878,55 @@ async def test_issues(hass: HomeAssistant, issue_registry: ir.IssueRegistry) ->
|
||||
assert_result_info(info, {})
|
||||
assert info.rate_limit is None
|
||||
|
||||
issue = ir.IssueEntry(
|
||||
active=False,
|
||||
breaks_in_ha_version="2025.12",
|
||||
created=dt_util.utcnow(),
|
||||
data=None,
|
||||
dismissed_version=None,
|
||||
domain="test",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain="test",
|
||||
issue_id="issue 2",
|
||||
learn_more_url=None,
|
||||
severity="warning",
|
||||
translation_key="abc_1234",
|
||||
translation_placeholders={"abc": "123"},
|
||||
)
|
||||
# Add non active issue
|
||||
issue_registry.issues[("test", "issue 2")] = issue
|
||||
# Test non active issue is omitted
|
||||
issue_entry = issue_registry.async_get_issue("test", "issue 2")
|
||||
assert issue_entry
|
||||
issue_2_created = issue_entry.created
|
||||
assert issue_entry and not issue_entry.active
|
||||
info = render_to_info(hass, "{{ issues() }}")
|
||||
assert_result_info(info, {})
|
||||
assert info.rate_limit is None
|
||||
|
||||
# Load and activate the issue
|
||||
ir.async_create_issue(
|
||||
hass=hass,
|
||||
breaks_in_ha_version="2025.12",
|
||||
data=None,
|
||||
domain="test",
|
||||
is_fixable=False,
|
||||
is_persistent=False,
|
||||
issue_domain="test",
|
||||
issue_id="issue 2",
|
||||
learn_more_url=None,
|
||||
severity="warning",
|
||||
translation_key="abc_1234",
|
||||
translation_placeholders={"abc": "123"},
|
||||
)
|
||||
activated_issue_entry = issue_registry.async_get_issue("test", "issue 2")
|
||||
assert activated_issue_entry and activated_issue_entry.active
|
||||
assert issue_2_created == activated_issue_entry.created
|
||||
info = render_to_info(hass, "{{ issues()['test', 'issue 2'] }}")
|
||||
assert_result_info(info, activated_issue_entry.to_json())
|
||||
assert info.rate_limit is None
|
||||
|
||||
|
||||
async def test_issue(hass: HomeAssistant, issue_registry: ir.IssueRegistry) -> None:
|
||||
"""Test issue function."""
|
||||
|
||||
Reference in New Issue
Block a user