Compare commits

..

60 Commits

Author SHA1 Message Date
Erik
5c0df09dc9 Remove template from sql service schema 2025-11-11 10:15:05 +01:00
Joost Lekkerkerker
ee0c4b15c2 Make certain fields required for subentry flows (#156251) 2025-11-11 09:42:51 +01:00
Erik Montnemery
507f54198e Use pytest.mark.freeze_time in habitica tests (#156332) 2025-11-11 09:37:17 +01:00
epenet
0ed342b433 Use dpcode_wrapper in tuya alarm control panel platform (#156306) 2025-11-11 09:36:09 +01:00
cdnninja
363c86faf3 Add remove entity to vesync (#156213) 2025-11-11 09:35:19 +01:00
dependabot[bot]
095a7ad060 Bump actions/dependency-review-action from 4.8.1 to 4.8.2 (#156322)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-11 09:34:38 +01:00
Åke Strandberg
ab5981bbbd Use common string for OAuth2 implementation error in myuplink (#156338) 2025-11-11 09:33:59 +01:00
Erik Montnemery
ac2fb53dfd Fix typo in recorder statistics_meta table manager (#156326) 2025-11-11 09:33:30 +01:00
Erik Montnemery
02ff5de1ff Use pytest.mark.freeze_time in ntfy tests (#156336) 2025-11-11 09:33:21 +01:00
Erik Montnemery
5cd5d480d9 Check collation of statistics_meta DB table (#156327) 2025-11-11 09:31:43 +01:00
Erik Montnemery
a3c7d772fc Use pytest.mark.freeze_time in conversation tests (#156329) 2025-11-11 09:29:46 +01:00
micha91
fe0c69dba7 Update aiomusiccast to 0.15 (#156325) 2025-11-11 09:26:16 +01:00
Artur Pragacz
e5365234c3 Add myself as codeowner to music assistant (#156324) 2025-11-11 09:24:09 +01:00
Erik Montnemery
1531175bd3 Use pytest.mark.freeze_time in google tests (#156330) 2025-11-11 09:22:48 +01:00
Erik Montnemery
62add59ff4 Use pytest.mark.freeze_time in google_generative_ai_conversation tests (#156331) 2025-11-11 09:21:52 +01:00
Erik Montnemery
d8daca657b Use pytest.mark.freeze_time in intellifire tests (#156333) 2025-11-11 10:17:58 +02:00
Erik Montnemery
1891da46ea Use pytest.mark.freeze_time in knx tests (#156335) 2025-11-11 08:52:39 +01:00
Marc Mueller
22ae894745 Update pytest-asyncio to 1.3.0 (#156315) 2025-11-10 22:07:02 -08:00
Will Moss
160810c69d Move oauth2_implementation_unavailable string to top level (#156299) 2025-11-11 06:58:24 +01:00
epenet
2ae23b920a Use dpcode_wrapper in tuya siren platform (#156284) 2025-11-10 23:06:14 +01:00
Artur Pragacz
a7edfb082f Move config intents to manager (#154903) 2025-11-10 16:04:25 -06:00
Ludovic BOUÉ
3ac203b05f Add Matter Aqara W100 fixture (#156305)
- Adds JSON fixture file containing Matter node data for the Aqara W100 sensor
- Updates test configuration to include the new fixture in parametrized tests
- Adds snapshot test data for sensor and button entities created by this device
2025-11-10 21:58:18 +01:00
Jan Bouwhuis
7c3eb19fc4 Fix issues() template method returns non active issues (#156274) 2025-11-10 21:56:57 +01:00
kingy444
70c6fac743 Move hunterdouglas_powerview data class to upstream library (#156228)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-10 14:49:00 -06:00
Åke Strandberg
e19d7250d5 Adjust user-facing string for miele (#156280) 2025-11-10 20:42:42 +01:00
Maikel Punie
a850d5dba7 Bump velbusaio to 2025.11.0 (#156293) 2025-11-10 21:25:00 +02:00
Erik Montnemery
0cf0f10654 Correct migration to recorder schema 51 (#156267)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-10 20:14:25 +01:00
Ludovic BOUÉ
8429f154ca Fix status checks in Matter binary sensors (#156276)
This PR fixes bitmap bit checking logic in Matter binary sensors by replacing equality comparisons with bitwise AND operations. The changes correct how the integration checks if specific bits are set in bitmap fields.

Key changes:

Changed equality checks (==) to bitwise AND operations (&) for checking bitmap bits
Wrapped bitwise operations with bool() to ensure boolean return values
Applied fixes consistently across PumpStatus, DishwasherAlarm, and RefrigeratorAlarm bitmaps
2025-11-10 19:45:17 +01:00
Assaf Inbal
7b4f5ad362 Ituran: Don't cache properties (#156281) 2025-11-10 19:24:58 +02:00
David Rapan
583b439557 Add Shelly number translation (#156156)
Signed-off-by: David Rapan <david@rapan.cz>
2025-11-10 19:15:16 +02:00
Michael Hansen
05922de102 Always chunk Wyoming TTS audio (#156079) 2025-11-10 10:40:45 -05:00
Khole
7675a44b90 Hive: Remove Alarm Support (#156184) 2025-11-10 16:32:38 +01:00
Simone Chemelli
1e4d645683 Fix config flow reconfigure for Comelit (#156193) 2025-11-10 16:28:47 +01:00
Glenn Vandeuren (aka Iondependent)
b5ae04605a Add climate platform for niko_home_control (#138087)
Co-authored-by: Christopher Fenner <9592452+CFenner@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: VandeurenGlenn <8685280+VandeurenGlenn@users.noreply.github.com>
2025-11-10 16:27:59 +01:00
Manu
2240d6b94c Enable trophy sensors also for friends in PlayStation Network integration (#156106) 2025-11-10 16:18:15 +01:00
Foscam-wangzhengyu
d1536ee636 Foscam Integration with Legacy Model Compatibility (#156226)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 16:14:57 +01:00
J. Nick Koston
8a926add7a Bump PySwitchbot to 0.73.0 (#156266) 2025-11-10 10:10:23 -05:00
J. Nick Koston
31f769900a Bump aiopvapi to 3.3.0 (#156268) 2025-11-10 10:06:58 -05:00
cdnninja
33ad777664 Add temp sensor to vesync humidifers (#155637)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 16:03:16 +01:00
Bouwe Westerdijk
59a4e4a337 Add Plugwise Adam zone profile select (#156262) 2025-11-10 16:00:26 +01:00
Andre Lengwenus
66a39933b0 Remove translations for non-existing service (#156265) 2025-11-10 15:53:00 +01:00
Heindrich Paul
ad395e3bba Add delay clean time support to Tuya integration for cat litter boxes (#156053) 2025-11-10 15:48:00 +01:00
hanwg
cfc6f2c229 Remove yaml in tests for Telegram polling bot (#156257) 2025-11-10 15:30:06 +01:00
Andrew Jackson
63aa41c766 Bump aiomealie to 1.1.0, adding recipe rating (#156256) 2025-11-10 15:28:45 +01:00
Tom Matheussen
037e0e93d3 Cleanup binary sensor platform for Satel Integra (#155915)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-10 15:13:49 +01:00
epenet
db8b5865b3 Improve Tuya event tests (#156259) 2025-11-10 15:03:23 +01:00
epenet
bd2ccc6672 Add tests for tuya button (#156252) 2025-11-10 14:54:51 +01:00
Joost Lekkerkerker
bb63d40cdf Bump pySmartThings to 3.3.2 (#156250) 2025-11-10 14:53:29 +01:00
Ludovic BOUÉ
65285b8885 Fix Matter ValveFault attribute handling (#156258)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-10 14:45:13 +01:00
Denis Shulyaka
326b8f2b4f Add AI task for Anthropic (#156221) 2025-11-10 14:01:28 +01:00
Heindrich Paul
9f3df52fcc Added light support to cat litter boxes (#156051) 2025-11-10 13:57:54 +01:00
wollew
875838c277 adjust naming of velux light entities according to guidelines (#155850) 2025-11-10 13:55:17 +01:00
epenet
adaafd1fda Use dpcode_wrapper in tuya binary sensor platform (#156247)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-10 13:54:09 +01:00
Heindrich Paul
50c5efddaa Add buttons for cat litter box devices (#156050) 2025-11-10 13:50:40 +01:00
epenet
c4be054161 Adjust Tuya DPCodeBooleanWrapper inheritance (#156255) 2025-11-10 13:39:09 +01:00
Bouwe Westerdijk
61186356f3 Refresh test-fixtures for Plugwise (#156253) 2025-11-10 13:35:24 +01:00
Will Moss
9d60a19440 Improved error handling for oauth2 configuration in volvo integration (#156215) 2025-11-10 13:17:48 +01:00
epenet
108c212855 Use dpcode_wrapper in tuya button platform (#156237) 2025-11-10 12:58:42 +01:00
Erik Montnemery
ae8db81c4e Use pytest.mark.freeze_time in ambient_network tests (#156241) 2025-11-10 12:50:43 +01:00
dotvav
51c970d1d0 Bump pypalazzetti lib from 0.1.19 to 0.1.20 (#156249) 2025-11-10 12:49:40 +01:00
153 changed files with 8100 additions and 5206 deletions

View File

@@ -622,7 +622,7 @@ jobs:
steps:
- *checkout
- name: Dependency review
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
with:
license-check: false # We use our own license audit checks

4
CODEOWNERS generated
View File

@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/msteams/ @peroyvind
/homeassistant/components/mullvad/ @meichthys
/tests/components/mullvad/ @meichthys
/homeassistant/components/music_assistant/ @music-assistant
/tests/components/music_assistant/ @music-assistant
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
/tests/components/music_assistant/ @music-assistant @arturpragacz
/homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core

View File

@@ -25,7 +25,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
)
PLATFORMS = (Platform.CONVERSATION,)
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]

View File

@@ -0,0 +1,80 @@
"""AI Task integration for Anthropic."""
from __future__ import annotations
from json import JSONDecodeError
import logging
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .entity import AnthropicBaseLLMEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue
async_add_entities(
[AnthropicTaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class AnthropicTaskEntity(
ai_task.AITaskEntity,
AnthropicBaseLLMEntity,
):
"""Anthropic AI Task entity."""
_attr_supported_features = (
ai_task.AITaskEntityFeature.GENERATE_DATA
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
)
async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(chat_log, task.name, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
if not task.structure:
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=text,
)
try:
data = json_loads(text)
except JSONDecodeError as err:
_LOGGER.error(
"Failed to parse JSON response: %s. Response: %s",
err,
text,
)
raise HomeAssistantError("Error with Claude structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)

View File

@@ -53,6 +53,7 @@ from .const import (
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
NON_THINKING_MODELS,
@@ -74,12 +75,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_OPTIONS = {
RECOMMENDED_CONVERSATION_OPTIONS = {
CONF_RECOMMENDED: True,
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
}
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
"""Validate the user input allows us to connect.
@@ -102,7 +107,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors = {}
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
@@ -130,10 +135,16 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
@@ -147,7 +158,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {"conversation": ConversationSubentryFlowHandler}
return {
"conversation": ConversationSubentryFlowHandler,
"ai_task_data": ConversationSubentryFlowHandler,
}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
@@ -164,7 +178,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a subentry."""
self.options = RECOMMENDED_OPTIONS.copy()
if self._subentry_type == "ai_task_data":
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
return await self.async_step_init()
async def async_step_reconfigure(
@@ -198,23 +215,29 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors: dict[str, str] = {}
if self._is_new:
step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = (
str
if self._subentry_type == "ai_task_data":
default_name = DEFAULT_AI_TASK_NAME
else:
default_name = DEFAULT_CONVERSATION_NAME
step_schema[vol.Required(CONF_NAME, default=default_name)] = str
if self._subentry_type == "conversation":
step_schema.update(
{
vol.Optional(CONF_PROMPT): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
}
)
step_schema.update(
{
vol.Optional(CONF_PROMPT): TemplateSelector(),
vol.Optional(
CONF_LLM_HASS_API,
): SelectSelector(
SelectSelectorConfig(options=hass_apis, multiple=True)
),
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
): bool,
}
)
step_schema[
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
)
] = bool
if user_input is not None:
if not user_input.get(CONF_LLM_HASS_API):
@@ -298,10 +321,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith(tuple(NON_THINKING_MODELS)):
step_schema[
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
] = NumberSelector(
NumberSelectorConfig(
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
)
] = vol.All(
NumberSelector(
NumberSelectorConfig(
min=0,
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
)
),
vol.Coerce(int),
)
else:
self.options.pop(CONF_THINKING_BUDGET, None)

View File

@@ -6,6 +6,7 @@ DOMAIN = "anthropic"
LOGGER = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "Claude conversation"
DEFAULT_AI_TASK_NAME = "Claude AI Task"
CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt"

View File

@@ -1,17 +1,24 @@
"""Base entity for Anthropic."""
import base64
from collections.abc import AsyncGenerator, Callable, Iterable
from dataclasses import dataclass, field
import json
from mimetypes import guess_file_type
from pathlib import Path
from typing import Any
import anthropic
from anthropic import AsyncStream
from anthropic.types import (
Base64ImageSourceParam,
Base64PDFSourceParam,
CitationsDelta,
CitationsWebSearchResultLocation,
CitationWebSearchResultLocationParam,
ContentBlockParam,
DocumentBlockParam,
ImageBlockParam,
InputJSONDelta,
MessageDeltaUsage,
MessageParam,
@@ -37,6 +44,9 @@ from anthropic.types import (
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolChoiceAnyParam,
ToolChoiceAutoParam,
ToolChoiceToolParam,
ToolParam,
ToolResultBlockParam,
ToolUnionParam,
@@ -50,13 +60,16 @@ from anthropic.types import (
WebSearchToolResultError,
)
from anthropic.types.message_create_params import MessageCreateParamsStreaming
import voluptuous as vol
from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, llm
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
from . import AnthropicConfigEntry
from .const import (
@@ -321,6 +334,7 @@ def _convert_content(
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
chat_log: conversation.ChatLog,
stream: AsyncStream[MessageStreamEvent],
output_tool: str | None = None,
) -> AsyncGenerator[
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
]:
@@ -381,6 +395,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
input="",
)
current_tool_args = ""
if response.content_block.name == output_tool:
if first_block or content_details.has_content():
if content_details.has_citations():
content_details.delete_empty()
yield {"native": content_details}
content_details = ContentDetails()
content_details.add_citation_detail()
yield {"role": "assistant"}
has_native = False
first_block = False
elif isinstance(response.content_block, TextBlock):
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
first_block
@@ -471,7 +495,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
first_block = True
elif isinstance(response, RawContentBlockDeltaEvent):
if isinstance(response.delta, InputJSONDelta):
current_tool_args += response.delta.partial_json
if (
current_tool_block is not None
and current_tool_block["name"] == output_tool
):
content_details.citation_details[-1].length += len(
response.delta.partial_json
)
yield {"content": response.delta.partial_json}
else:
current_tool_args += response.delta.partial_json
elif isinstance(response.delta, TextDelta):
content_details.citation_details[-1].length += len(response.delta.text)
yield {"content": response.delta.text}
@@ -490,6 +523,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
content_details.add_citation(response.delta.citation)
elif isinstance(response, RawContentBlockStopEvent):
if current_tool_block is not None:
if current_tool_block["name"] == output_tool:
current_tool_block = None
continue
tool_args = json.loads(current_tool_args) if current_tool_args else {}
current_tool_block["input"] = tool_args
yield {
@@ -557,6 +593,8 @@ class AnthropicBaseLLMEntity(Entity):
async def _async_handle_chat_log(
self,
chat_log: conversation.ChatLog,
structure_name: str | None = None,
structure: vol.Schema | None = None,
) -> None:
"""Generate an answer for the chat log."""
options = self.subentry.data
@@ -613,6 +651,74 @@ class AnthropicBaseLLMEntity(Entity):
}
tools.append(web_search)
# Handle attachments by adding them to the last user message
last_content = chat_log.content[-1]
if last_content.role == "user" and last_content.attachments:
last_message = messages[-1]
if last_message["role"] != "user":
raise HomeAssistantError(
"Last message must be a user message to add attachments"
)
if isinstance(last_message["content"], str):
last_message["content"] = [
TextBlockParam(type="text", text=last_message["content"])
]
last_message["content"].extend( # type: ignore[union-attr]
await async_prepare_files_for_prompt(
self.hass, [(a.path, a.mime_type) for a in last_content.attachments]
)
)
if structure and structure_name:
structure_name = slugify(structure_name)
if model_args["thinking"]["type"] == "disabled":
if not tools:
# Simplest case: no tools and no extended thinking
# Add a tool and force its use
model_args["tool_choice"] = ToolChoiceToolParam(
type="tool",
name=structure_name,
)
else:
# Second case: tools present but no extended thinking
# Allow the model to use any tool but not text response
# The model should know to use the right tool by its description
model_args["tool_choice"] = ToolChoiceAnyParam(
type="any",
)
else:
# Extended thinking is enabled. With extended thinking, we cannot
# force tool use or disable text responses, so we add a hint to the
# system prompt instead. With extended thinking, the model should be
# smart enough to use the tool.
model_args["tool_choice"] = ToolChoiceAutoParam(
type="auto",
)
if isinstance(model_args["system"], str):
model_args["system"] = [
TextBlockParam(type="text", text=model_args["system"])
]
model_args["system"].append( # type: ignore[union-attr]
TextBlockParam(
type="text",
text=f"Claude MUST use the '{structure_name}' tool to provide the final answer instead of plain text.",
)
)
tools.append(
ToolParam(
name=structure_name,
description="Use this tool to reply to the user",
input_schema=convert(
structure,
custom_serializer=chat_log.llm_api.custom_serializer
if chat_log.llm_api
else llm.selector_serializer,
),
)
)
if tools:
model_args["tools"] = tools
@@ -629,7 +735,11 @@ class AnthropicBaseLLMEntity(Entity):
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(chat_log, stream),
_transform_stream(
chat_log,
stream,
output_tool=structure_name if structure else None,
),
)
]
)
@@ -641,3 +751,59 @@ class AnthropicBaseLLMEntity(Entity):
if not chat_log.unresponded_tool_results:
break
async def async_prepare_files_for_prompt(
hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
"""Append files to a prompt.
Caller needs to ensure that the files are allowed.
"""
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
content: list[ImageBlockParam | DocumentBlockParam] = []
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(f"`{file_path}` does not exist")
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
if mime_type.startswith("image/"):
content.append(
ImageBlockParam(
type="image",
source=Base64ImageSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
elif mime_type.startswith("application/pdf"):
content.append(
DocumentBlockParam(
type="document",
source=Base64PDFSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
return content
return await hass.async_add_executor_job(append_files_to_content)

View File

@@ -18,6 +18,49 @@
}
},
"config_subentries": {
"ai_task_data": {
"abort": {
"entry_not_loaded": "[%key:component::anthropic::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "AI task",
"initiate_flow": {
"reconfigure": "Reconfigure AI task",
"user": "Add AI task"
},
"step": {
"advanced": {
"data": {
"chat_model": "[%key:common::generic::model%]",
"max_tokens": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::max_tokens%]",
"temperature": "[%key:component::anthropic::config_subentries::conversation::step::advanced::data::temperature%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::advanced::title%]"
},
"init": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"recommended": "[%key:component::anthropic::config_subentries::conversation::step::init::data::recommended%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::init::title%]"
},
"model": {
"data": {
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
},
"data_description": {
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
"web_search": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search%]",
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::web_search_max_uses%]"
},
"title": "[%key:component::anthropic::config_subentries::conversation::step::model::title%]"
}
}
},
"conversation": {
"abort": {
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
@@ -46,7 +89,8 @@
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template."
}
},
"title": "Basic settings"
},
"model": {
"data": {

View File

@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
STEP_RECONFIGURE = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_PORT): cv.port,
vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.string,
}
)
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
@@ -175,36 +168,55 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle reconfiguration of the device."""
reconfigure_entry = self._get_reconfigure_entry()
if not user_input:
return self.async_show_form(
step_id="reconfigure", data_schema=STEP_RECONFIGURE
)
updated_host = user_input[CONF_HOST]
self._async_abort_entries_match({CONF_HOST: updated_host})
errors: dict[str, str] = {}
try:
await validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates={CONF_HOST: updated_host}
)
if user_input is not None:
updated_host = user_input[CONF_HOST]
self._async_abort_entries_match({CONF_HOST: updated_host})
try:
data_to_validate = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
CONF_TYPE: reconfigure_entry.data.get(CONF_TYPE, BRIDGE),
}
await validate_input(self.hass, data_to_validate)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except InvalidPin:
errors["base"] = "invalid_pin"
except Exception: # noqa: BLE001
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
data_updates = {
CONF_HOST: updated_host,
CONF_PORT: user_input[CONF_PORT],
CONF_PIN: user_input[CONF_PIN],
}
return self.async_update_reload_and_abort(
reconfigure_entry, data_updates=data_updates
)
schema = vol.Schema(
{
vol.Required(
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
): cv.string,
vol.Required(
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
): cv.port,
vol.Optional(CONF_PIN): cv.string,
}
)
return self.async_show_form(
step_id="reconfigure",
data_schema=STEP_RECONFIGURE,
data_schema=schema,
errors=errors,
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
from typing import Literal
from typing import Any, Literal
from hassil.recognize import RecognizeResult
import voluptuous as vol
@@ -21,6 +21,7 @@ from homeassistant.core import (
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, intent
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.reload import async_integration_yaml_config
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass
@@ -52,6 +53,8 @@ from .const import (
DATA_COMPONENT,
DOMAIN,
HOME_ASSISTANT_AGENT,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
agent_config = config.get(DOMAIN, {})
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {})
)
manager = get_agent_manager(hass)
hass_config_path = hass.config.path()
config_intents = _get_config_intents(config, hass_config_path)
manager.update_config_intents(config_intents)
await async_setup_default_agent(hass, entity_component)
async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands."""
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None:
"""Reload intents."""
agent = get_agent_manager(hass).default_agent
language = service.data.get(ATTR_LANGUAGE)
if language is None:
conf = await async_integration_yaml_config(hass, DOMAIN)
if conf is not None:
config_intents = _get_config_intents(conf, hass_config_path)
manager.update_config_intents(config_intents)
agent = manager.default_agent
if agent is not None:
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
await agent.async_reload(language=language)
hass.services.async_register(
DOMAIN,
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
def _get_config_intents(config: ConfigType, hass_config_path: str) -> dict[str, Any]:
"""Return config intents."""
intents = config.get(DOMAIN, {}).get("intents", {})
return {
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in intents.items()
}
}
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)

View File

@@ -147,6 +147,7 @@ class AgentManager:
self.hass = hass
self._agents: dict[str, AbstractConversationAgent] = {}
self.default_agent: DefaultAgent | None = None
self.config_intents: dict[str, Any] = {}
self.triggers_details: list[TriggerDetails] = []
@callback
@@ -199,9 +200,16 @@ class AgentManager:
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
"""Set up the default agent."""
agent.update_config_intents(self.config_intents)
agent.update_triggers(self.triggers_details)
self.default_agent = agent
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self.config_intents = intents
if self.default_agent is not None:
self.default_agent.update_config_intents(intents)
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
"""Register a trigger."""
self.triggers_details.append(trigger_details)

View File

@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity."""
CONTROL = 1
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"

View File

@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
from .agent_manager import get_agent_manager
from .chat_log import AssistantContent, ChatLog
from .const import DOMAIN, ConversationEntityFeature
from .const import (
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
ERROR_SENTINEL = object()
@@ -202,10 +205,9 @@ class IntentCache:
async def async_setup_default_agent(
hass: HomeAssistant,
entity_component: EntityComponent[ConversationEntity],
config_intents: dict[str, Any],
) -> None:
"""Set up entity registry listener for the default agent."""
agent = DefaultAgent(hass, config_intents)
agent = DefaultAgent(hass)
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock()
# intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents
# Intents from common conversation config
self._config_intents: dict[str, Any] = {}
# Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = []
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
@callback
def update_config_intents(self, intents: dict[str, Any]) -> None:
"""Update config intents."""
self._config_intents = intents
# Intents have changed, so we must clear the cache
self._intent_cache.clear()
async def async_prepare(self, language: str | None = None) -> None:
"""Load intents for a language."""
if language is None:
@@ -1159,33 +1169,10 @@ class DefaultAgent(ConversationEntity):
custom_sentences_path,
)
# Load sentences from HA config for default language only
if self._config_intents and (
self.hass.config.language in (language, language_variant)
):
hass_config_path = self.hass.config.path()
merge_dict(
intents_dict,
{
"intents": {
intent_name: {
"data": [
{
"sentences": sentences,
"metadata": {
METADATA_CUSTOM_SENTENCE: True,
METADATA_CUSTOM_FILE: hass_config_path,
},
}
]
}
for intent_name, sentences in self._config_intents.items()
}
},
)
_LOGGER.debug(
"Loaded intents from configuration.yaml",
)
merge_dict(
intents_dict,
self._config_intents,
)
if not intents_dict:
return None

View File

@@ -116,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None
is_open_hdr = None
reserve3 = product_info.get("reserve4")
reserve3_int = int(reserve3) if reserve3 is not None else 0
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
if supports_wdr_adjustment_val:
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
mode = is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val:
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
is_open_hdr = bool(int(mode))
model = product_info.get("model")
model_int = int(model) if model is not None else 7002
if model_int > 7001:
reserve3_int = int(reserve3) if reserve3 is not None else 0
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
if supports_wdr_adjustment_val:
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
mode = (
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
)
is_open_wdr = bool(int(mode))
elif supports_hdr_adjustment_val:
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
mode = (
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
)
is_open_hdr = bool(int(mode))
else:
supports_wdr_adjustment_val = False
supports_hdr_adjustment_val = False
ret_sw, software_capabilities = self.session.getSWCapabilities()
supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0

View File

@@ -1,77 +0,0 @@
"""Support for the Hive alarm."""
from __future__ import annotations
from datetime import timedelta
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HiveConfigEntry
from .entity import HiveEntity
PARALLEL_UPDATES = 0
SCAN_INTERVAL = timedelta(seconds=15)
HIVETOHA = {
"home": AlarmControlPanelState.DISARMED,
"asleep": AlarmControlPanelState.ARMED_NIGHT,
"away": AlarmControlPanelState.ARMED_AWAY,
"sos": AlarmControlPanelState.TRIGGERED,
}
async def async_setup_entry(
hass: HomeAssistant,
entry: HiveConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Hive thermostat based on a config entry."""
hive = entry.runtime_data
if devices := hive.session.deviceList.get("alarm_control_panel"):
async_add_entities(
[HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True
)
class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
"""Representation of a Hive alarm."""
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_NIGHT
| AlarmControlPanelEntityFeature.ARM_AWAY
| AlarmControlPanelEntityFeature.TRIGGER
)
_attr_code_arm_required = False
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
await self.hive.alarm.setMode(self.device, "home")
async def async_alarm_arm_night(self, code: str | None = None) -> None:
"""Send arm night command."""
await self.hive.alarm.setMode(self.device, "asleep")
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
await self.hive.alarm.setMode(self.device, "away")
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send alarm trigger command."""
await self.hive.alarm.setMode(self.device, "sos")
async def async_update(self) -> None:
"""Update all Node data from Hive."""
await self.hive.session.updateData(self.device)
self.device = await self.hive.alarm.getAlarm(self.device)
self._attr_available = self.device["deviceData"].get("online")
if self._attr_available:
if self.device["status"]["state"]:
self._attr_alarm_state = AlarmControlPanelState.TRIGGERED
else:
self._attr_alarm_state = HIVETOHA[self.device["status"]["mode"]]

View File

@@ -11,7 +11,6 @@ CONFIG_ENTRY_VERSION = 1
DEFAULT_NAME = "Hive"
DOMAIN = "hive"
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
@@ -20,7 +19,6 @@ PLATFORMS = [
Platform.WATER_HEATER,
]
PLATFORM_LOOKUP = {
Platform.ALARM_CONTROL_PANEL: "alarm_control_panel",
Platform.BINARY_SENSOR: "binary_sensor",
Platform.CLIMATE: "climate",
Platform.LIGHT: "light",

View File

@@ -1237,7 +1237,7 @@
"message": "Error obtaining data from the API: {error}"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"pause_program": {
"message": "Error pausing program: {error}"

View File

@@ -4,6 +4,7 @@ import logging
from typing import TYPE_CHECKING
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.rooms import Rooms
from aiopvapi.scenes import Scenes
from aiopvapi.shades import Shades
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewConfigEntry, PowerviewEntryData
from .shade_data import PowerviewShadeData
from .util import async_connect_hub
PARALLEL_UPDATES = 1

View File

@@ -8,6 +8,7 @@ import logging
from aiopvapi.helpers.aiorequest import PvApiMaintenance
from aiopvapi.hub import Hub
from aiopvapi.resources.shade_data import PowerviewShadeData
from aiopvapi.shades import Shades
from homeassistant.config_entries import ConfigEntry
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import HUB_EXCEPTIONS
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)

View File

@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
async def _async_execute_move(self, move: ShadePosition) -> None:
"""Execute a move that can affect multiple positions."""
_LOGGER.debug("Move request %s: %s", self.name, move)
# Store the requested positions so subsequent move
# requests contain the secondary shade positions
self.data.update_shade_position(self._shade.id, move)
async with self.coordinator.radio_operation_lock:
response = await self._shade.move(move)
_LOGGER.debug("Move response %s: %s", self.name, response)
# Process the response from the hub (including new positions)
self.data.update_shade_position(self._shade.id, response)
async def _async_set_cover_position(self, target_hass_position: int) -> None:
"""Move the shade to a position."""
target_hass_position = self._clamp_cover_limit(target_hass_position)

View File

@@ -3,6 +3,7 @@
import logging
from aiopvapi.resources.shade import BaseShade, ShadePosition
from aiopvapi.resources.shade_data import PowerviewShadeData
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER
from .coordinator import PowerviewShadeUpdateCoordinator
from .model import PowerviewDeviceInfo
from .shade_data import PowerviewShadeData
_LOGGER = logging.getLogger(__name__)

View File

@@ -18,6 +18,6 @@
},
"iot_class": "local_polling",
"loggers": ["aiopvapi"],
"requirements": ["aiopvapi==3.2.1"],
"requirements": ["aiopvapi==3.3.0"],
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
}

View File

@@ -1,80 +0,0 @@
"""Shade data for the Hunter Douglas PowerView integration."""
from __future__ import annotations
from dataclasses import fields
from typing import Any
from aiopvapi.resources.model import PowerviewData
from aiopvapi.resources.shade import BaseShade, ShadePosition
from .util import async_map_data_by_id
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
"""Copy position data from source to target for None values only."""
for field in POSITION_FIELDS:
if (value := getattr(source, field.name)) is not None:
setattr(target, field.name, value)
class PowerviewShadeData:
"""Coordinate shade data between multiple api calls."""
def __init__(self) -> None:
"""Init the shade data."""
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
self._shade_group_data_by_id: dict[int, BaseShade] = {}
self.positions: dict[int, ShadePosition] = {}
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
"""Get data for the shade."""
return self._raw_data_by_id[shade_id]
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
"""Get data for all shades."""
return self._raw_data_by_id
def get_shade(self, shade_id: int) -> BaseShade:
"""Get specific shade from the coordinator."""
return self._shade_group_data_by_id[shade_id]
def get_shade_position(self, shade_id: int) -> ShadePosition:
"""Get positions for a shade."""
if shade_id not in self.positions:
shade_position = ShadePosition()
# If we have the group data, use it to populate the initial position
if shade := self._shade_group_data_by_id.get(shade_id):
copy_position_data(shade.current_position, shade_position)
self.positions[shade_id] = shade_position
return self.positions[shade_id]
def update_from_group_data(self, shade_id: int) -> None:
"""Process an update from the group data."""
data = self._shade_group_data_by_id[shade_id]
copy_position_data(data.current_position, self.get_shade_position(data.id))
def store_group_data(self, shade_data: PowerviewData) -> None:
"""Store data from the all shades endpoint.
This does not update the shades or positions (self.positions)
as the data may be stale. update_from_group_data
with a shade_id will update a specific shade
from the group data.
"""
self._shade_group_data_by_id = shade_data.processed
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
"""Update a single shades position."""
copy_position_data(new_position, self.get_shade_position(shade_id))
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
"""Update a single shades velocity."""
# the hub will always return a velocity of 0 on initial connect,
# separate definition to store consistent value in HA
# this value is purely driven from HA
if shade_data.velocity is not None:
self.get_shade_position(shade_id).velocity = shade_data.velocity

View File

@@ -2,25 +2,15 @@
from __future__ import annotations
from collections.abc import Iterable
from typing import Any
from aiopvapi.helpers.aiorequest import AioRequest
from aiopvapi.helpers.constants import ATTR_ID
from aiopvapi.hub import Hub
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .model import PowerviewAPI, PowerviewDeviceInfo
@callback
def async_map_data_by_id(data: Iterable[dict[str | int, Any]]):
"""Return a dict with the key being the id for a list of entries."""
return {entry[ATTR_ID]: entry for entry in data}
async def async_connect_hub(
hass: HomeAssistant, address: str, api_version: int | None = None
) -> PowerviewAPI:

View File

@@ -5,7 +5,6 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from propcache.api import cached_property
from pyituran import Vehicle
from homeassistant.components.binary_sensor import (
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@cached_property
@property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle)

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
from propcache.api import cached_property
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker")
@cached_property
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self.vehicle.gps_coordinates[0]
@cached_property
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self.vehicle.gps_coordinates[1]

View File

@@ -6,7 +6,6 @@ from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from propcache.api import cached_property
from pyituran import Vehicle
from homeassistant.components.sensor import (
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@cached_property
@property
def native_value(self) -> StateType | datetime:
"""Return the state of the device."""
return self.entity_description.value_fn(self.vehicle)

View File

@@ -94,28 +94,6 @@
}
},
"services": {
"address_to_device_id": {
"description": "Converts an LCN address into a device ID.",
"fields": {
"host": {
"description": "Host name as given in the integration panel.",
"name": "Host name"
},
"id": {
"description": "Module or group number of the target.",
"name": "Module or group ID"
},
"segment_id": {
"description": "Segment number of the target.",
"name": "Segment ID"
},
"type": {
"description": "Module type of the target.",
"name": "Type"
}
},
"name": "Address to device ID"
},
"dyn_text": {
"description": "Sends dynamic text to LCN-GTxD displays.",
"fields": {

View File

@@ -353,17 +353,13 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled
device_to_ha={
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kLocalOverride: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemotePressure: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteFlow: False,
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRemoteTemperature: False,
}.get,
device_to_ha=lambda x: bool(
x
& (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
)
),
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -377,9 +373,9 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning",
translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: (
device_to_ha=lambda x: bool(
x
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
),
),
entity_class=MatterBinarySensor,
@@ -395,8 +391,8 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
),
),
entity_class=MatterBinarySensor,
@@ -410,8 +406,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
),
),
entity_class=MatterBinarySensor,
@@ -425,9 +421,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 +440,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 +459,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,
@@ -478,8 +477,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: (
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
device_to_ha=lambda x: bool(
x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
),
),
entity_class=MatterBinarySensor,

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.0.1"]
"requirements": ["aiomealie==1.1.0"]
}

View File

@@ -1009,7 +1009,7 @@
"cleaning_care_program": "Cleaning/care program",
"maintenance_program": "Maintenance program",
"normal_operation_mode": "Normal operation mode",
"own_program": "Own program"
"own_program": "Program"
}
},
"remaining_time": {
@@ -1089,7 +1089,7 @@
"message": "Invalid device targeted."
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"set_program_error": {
"message": "'Set program' action failed: {status} / {message}"

View File

@@ -2,7 +2,7 @@
"domain": "music_assistant",
"name": "Music Assistant",
"after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant"],
"codeowners": ["@music-assistant", "@arturpragacz"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",

View File

@@ -57,7 +57,7 @@
"message": "Error while loading the integration."
},
"implementation_unavailable": {
"message": "OAuth2 implementation is not available, will retry."
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"incorrect_oauth2_scope": {
"message": "Stored permissions are invalid. Please login again to update permissions."

View File

@@ -12,7 +12,12 @@ from homeassistant.helpers import entity_registry as er
from .const import _LOGGER
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE]
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
]
type NikoHomeControlConfigEntry = ConfigEntry[NHCController]

View File

@@ -0,0 +1,100 @@
"""Support for Niko Home Control thermostats."""
from typing import Any
from nhc.const import THERMOSTAT_MODES, THERMOSTAT_MODES_REVERSE
from nhc.thermostat import NHCThermostat
from homeassistant.components.climate import (
PRESET_ECO,
ClimateEntity,
ClimateEntityFeature,
HVACMode,
)
from homeassistant.components.sensor import UnitOfTemperature
from homeassistant.const import ATTR_TEMPERATURE
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NikoHomeControlConfigEntry
from .const import (
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP,
NikoHomeControlThermostatModes,
)
from .entity import NikoHomeControlEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: NikoHomeControlConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Niko Home Control thermostat entry."""
controller = entry.runtime_data
async_add_entities(
NikoHomeControlClimate(thermostat, controller, entry.entry_id)
for thermostat in controller.thermostats.values()
)
class NikoHomeControlClimate(NikoHomeControlEntity, ClimateEntity):
"""Representation of a Niko Home Control thermostat."""
_attr_supported_features: ClimateEntityFeature = (
ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
)
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_name = None
_action: NHCThermostat
_attr_translation_key = "nhc_thermostat"
_attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO]
_attr_preset_modes = [
"day",
"night",
PRESET_ECO,
"prog1",
"prog2",
"prog3",
]
def _get_niko_mode(self, mode: str) -> int:
"""Return the Niko mode."""
return THERMOSTAT_MODES_REVERSE.get(mode, NikoHomeControlThermostatModes.OFF)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if ATTR_TEMPERATURE in kwargs:
await self._action.set_temperature(kwargs.get(ATTR_TEMPERATURE))
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
await self._action.set_mode(self._get_niko_mode(preset_mode))
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target hvac mode."""
await self._action.set_mode(NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP[hvac_mode])
async def async_turn_off(self) -> None:
"""Turn thermostat off."""
await self._action.set_mode(NikoHomeControlThermostatModes.OFF)
def update_state(self) -> None:
"""Update the state of the entity."""
if self._action.state == NikoHomeControlThermostatModes.OFF:
self._attr_hvac_mode = HVACMode.OFF
self._attr_preset_mode = None
elif self._action.state == NikoHomeControlThermostatModes.COOL:
self._attr_hvac_mode = HVACMode.COOL
self._attr_preset_mode = None
else:
self._attr_hvac_mode = HVACMode.AUTO
self._attr_preset_mode = THERMOSTAT_MODES[self._action.state]
self._attr_target_temperature = self._action.setpoint
self._attr_current_temperature = self._action.measured

View File

@@ -1,6 +1,23 @@
"""Constants for niko_home_control integration."""
from enum import IntEnum
import logging
from homeassistant.components.climate import HVACMode
DOMAIN = "niko_home_control"
_LOGGER = logging.getLogger(__name__)
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP = {
HVACMode.OFF: 3,
HVACMode.COOL: 4,
HVACMode.AUTO: 5,
}
class NikoHomeControlThermostatModes(IntEnum):
"""Enum for Niko Home Control thermostat modes."""
OFF = 3
COOL = 4
AUTO = 5

View File

@@ -0,0 +1,20 @@
{
"entity": {
"climate": {
"nhc_thermostat": {
"state_attributes": {
"preset_mode": {
"default": "mdi:calendar-clock",
"state": {
"day": "mdi:weather-sunny",
"night": "mdi:weather-night",
"prog1": "mdi:numeric-1",
"prog2": "mdi:numeric-2",
"prog3": "mdi:numeric-3"
}
}
}
}
}
}
}

View File

@@ -26,5 +26,23 @@
"description": "Set up your Niko Home Control instance."
}
}
},
"entity": {
"climate": {
"nhc_thermostat": {
"state_attributes": {
"preset_mode": {
"state": {
"day": "Day",
"eco": "Eco",
"night": "Night",
"prog1": "Program 1",
"prog2": "Program 2",
"prog3": "Program 3"
}
}
}
}
}
}
}

View File

@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pypalazzetti==0.1.19"]
"requirements": ["pypalazzetti==0.1.20"]
}

View File

@@ -256,6 +256,7 @@ class PlaystationNetworkFriendDataCoordinator(
account_id=self.user.account_id,
presence=self.user.get_presence(),
profile=self.profile,
trophy_summary=self.user.trophy_summary(),
)
except PSNAWPForbiddenError as error:
raise UpdateFailed(

View File

@@ -54,7 +54,7 @@ class PlaystationNetworkSensor(StrEnum):
NOW_PLAYING = "now_playing"
SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.TROPHY_LEVEL,
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
@@ -106,8 +106,6 @@ SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...
else None
),
),
)
SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_ID,
translation_key=PlaystationNetworkSensor.ONLINE_ID,
@@ -152,7 +150,7 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.user_data
async_add_entities(
PlaystationNetworkSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER
for description in SENSOR_DESCRIPTIONS
)
for (
@@ -166,7 +164,7 @@ async def async_setup_entry(
description,
config_entry.subentries[subentry_id],
)
for description in SENSOR_DESCRIPTIONS_USER
for description in SENSOR_DESCRIPTIONS
],
config_subentry_id=subentry_id,
)

View File

@@ -57,12 +57,14 @@ type SelectType = Literal[
"select_gateway_mode",
"select_regulation_mode",
"select_schedule",
"select_zone_profile",
]
type SelectOptionsType = Literal[
"available_schedules",
"dhw_modes",
"gateway_modes",
"regulation_modes",
"available_schedules",
"zone_profiles",
]
# Default directives
@@ -82,3 +84,10 @@ MASTER_THERMOSTATS: Final[list[str]] = [
"zone_thermometer",
"zone_thermostat",
]
# Select constants
SELECT_DHW_MODE: Final = "select_dhw_mode"
SELECT_GATEWAY_MODE: Final = "select_gateway_mode"
SELECT_REGULATION_MODE: Final = "select_regulation_mode"
SELECT_SCHEDULE: Final = "select_schedule"
SELECT_ZONE_PROFILE: Final = "select_zone_profile"

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.9.0"],
"requirements": ["plugwise==1.10.0"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@@ -9,7 +9,15 @@ from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import SelectOptionsType, SelectType
from .const import (
SELECT_DHW_MODE,
SELECT_GATEWAY_MODE,
SELECT_REGULATION_MODE,
SELECT_SCHEDULE,
SELECT_ZONE_PROFILE,
SelectOptionsType,
SelectType,
)
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
@@ -27,28 +35,34 @@ class PlugwiseSelectEntityDescription(SelectEntityDescription):
SELECT_TYPES = (
PlugwiseSelectEntityDescription(
key="select_schedule",
translation_key="select_schedule",
key=SELECT_SCHEDULE,
translation_key=SELECT_SCHEDULE,
options_key="available_schedules",
),
PlugwiseSelectEntityDescription(
key="select_regulation_mode",
translation_key="regulation_mode",
key=SELECT_REGULATION_MODE,
translation_key=SELECT_REGULATION_MODE,
entity_category=EntityCategory.CONFIG,
options_key="regulation_modes",
),
PlugwiseSelectEntityDescription(
key="select_dhw_mode",
translation_key="dhw_mode",
key=SELECT_DHW_MODE,
translation_key=SELECT_DHW_MODE,
entity_category=EntityCategory.CONFIG,
options_key="dhw_modes",
),
PlugwiseSelectEntityDescription(
key="select_gateway_mode",
translation_key="gateway_mode",
key=SELECT_GATEWAY_MODE,
translation_key=SELECT_GATEWAY_MODE,
entity_category=EntityCategory.CONFIG,
options_key="gateway_modes",
),
PlugwiseSelectEntityDescription(
key=SELECT_ZONE_PROFILE,
translation_key=SELECT_ZONE_PROFILE,
entity_category=EntityCategory.CONFIG,
options_key="zone_profiles",
),
)

View File

@@ -109,7 +109,7 @@
}
},
"select": {
"dhw_mode": {
"select_dhw_mode": {
"name": "DHW mode",
"state": {
"auto": "[%key:common::state::auto%]",
@@ -118,7 +118,7 @@
"off": "[%key:common::state::off%]"
}
},
"gateway_mode": {
"select_gateway_mode": {
"name": "Gateway mode",
"state": {
"away": "Pause",
@@ -126,7 +126,7 @@
"vacation": "Vacation"
}
},
"regulation_mode": {
"select_regulation_mode": {
"name": "Regulation mode",
"state": {
"bleeding_cold": "Bleeding cold",
@@ -141,6 +141,14 @@
"state": {
"off": "[%key:common::state::off%]"
}
},
"select_zone_profile": {
"name": "Zone profile",
"state": {
"active": "[%key:common::state::active%]",
"off": "[%key:common::state::off%]",
"passive": "Passive"
}
}
},
"sensor": {

View File

@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
schema_errors |= validate_table_schema_supports_utf8(
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
)
schema_errors |= validate_table_schema_has_correct_collation(
instance, StatisticsMeta
)
for table in (Statistics, StatisticsShortTerm):
schema_errors |= validate_db_schema_precision(instance, table)
schema_errors |= validate_table_schema_has_correct_collation(instance, table)

View File

@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
STATES_META_SCHEMA_VERSION = 38
CIRCULAR_MEAN_SCHEMA_VERSION = 49
UNIT_CLASS_SCHEMA_VERSION = 51
UNIT_CLASS_SCHEMA_VERSION = 52
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 51
SCHEMA_VERSION = 52
_LOGGER = logging.getLogger(__name__)

View File

@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID
import sqlalchemy
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import (
DatabaseError,
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.session import Session
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
from sqlalchemy.sql.expression import true
from sqlalchemy.sql.expression import and_, true
from sqlalchemy.sql.lambdas import StatementLambdaElement
from sqlalchemy.types import BINARY
from homeassistant.core import HomeAssistant
from homeassistant.util.enum import try_parse_enum
@@ -2044,14 +2053,74 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None:
"""Version specific update method."""
# Add unit class column to StatisticsMeta
# Replaced with version 52 which corrects issues with MySQL string comparisons.
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
def _apply_update(self) -> None:
"""Version specific update method."""
if self.engine.dialect.name == SupportedDialect.MYSQL:
self._apply_update_mysql()
else:
self._apply_update_postgresql_sqlite()
def _apply_update_mysql(self) -> None:
"""Version specific update method for mysql."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
case_sensitive_units = {
u.encode("utf-8") if u else u for u in conv.VALID_UNITS
}
# Reset unit_class to None for entries that do not match
# the valid units (case sensitive) but matched before due to
# case insensitive comparisons.
connection.execute(
update(StatisticsMeta)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
case_sensitive_units
),
)
)
.values(unit_class=None)
)
# Do an explicitly case sensitive match (actually binary) to set the
# correct unit_class. This is needed because we use the case sensitive
# utf8mb4_unicode_ci collation.
connection.execute(
update(StatisticsMeta)
.where(
and_(
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
case_sensitive_units
),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)
def _apply_update_postgresql_sqlite(self) -> None:
"""Version specific update method for postgresql and sqlite."""
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
with session_scope(session=self.session_maker()) as session:
connection = session.connection()
for conv in _PRIMARY_UNIT_CONVERTERS:
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
# have case sensitive string comparisons by default, so we
# can directly match on the valid units.
connection.execute(
update(StatisticsMeta)
.where(
and_(
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
StatisticsMeta.unit_class.is_(None),
)
)
.values(unit_class=conv.UNIT_CLASS)
)

View File

@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
_LOGGER = logging.getLogger(__name__)
QUERY_STATISTIC_META = (
QUERY_STATISTICS_META = (
StatisticsMeta.id,
StatisticsMeta.statistic_id,
StatisticsMeta.source,
@@ -55,7 +55,7 @@ def _generate_get_metadata_stmt(
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
"""
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type)
else:

View File

@@ -2,12 +2,15 @@
from __future__ import annotations
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -17,6 +20,7 @@ from .const import (
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
@@ -40,9 +44,9 @@ async def async_setup_entry(
)
for subentry in zone_subentries:
zone_num = subentry.data[CONF_ZONE_NUMBER]
zone_type = subentry.data[CONF_ZONE_TYPE]
zone_name = subentry.data[CONF_NAME]
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
@@ -65,9 +69,9 @@ async def async_setup_entry(
)
for subentry in output_subentries:
output_num = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type = subentry.data[CONF_ZONE_TYPE]
output_name = subentry.data[CONF_NAME]
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
@@ -89,68 +93,48 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
"""Representation of an Satel Integra binary sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller,
device_number,
device_name,
zone_type,
sensor_type,
react_to_signal,
config_entry_id,
):
controller: AsyncSatel,
device_number: int,
device_name: str,
device_class: BinarySensorDeviceClass,
sensor_type: str,
react_to_signal: str,
config_entry_id: str,
) -> None:
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._name = device_name
self._zone_type = zone_type
self._state = 0
self._react_to_signal = react_to_signal
self._satel = controller
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
if self._device_number in self._satel.violated_outputs:
self._state = 1
else:
self._state = 0
elif self._device_number in self._satel.violated_zones:
self._state = 1
self._attr_is_on = self._device_number in self._satel.violated_outputs
else:
self._state = 0
self._attr_is_on = self._device_number in self._satel.violated_zones
self.async_on_remove(
async_dispatcher_connect(
self.hass, self._react_to_signal, self._devices_updated
)
)
@property
def name(self):
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str | None:
"""Icon for device by its type."""
if self._zone_type is BinarySensorDeviceClass.SMOKE:
return "mdi:fire"
return None
@property
def is_on(self):
"""Return true if sensor is on."""
return self._state == 1
@property
def device_class(self):
"""Return the class of this sensor, from DEVICE_CLASSES."""
return self._zone_type
@callback
def _devices_updated(self, zones):
def _devices_updated(self, zones: dict[int, int]):
"""Update the zone's state, if needed."""
if self._device_number in zones and self._state != zones[self._device_number]:
self._state = zones[self._device_number]
self.async_write_ha_state()
if self._device_number in zones:
new_state = zones[self._device_number] == 1
if new_state != self._attr_is_on:
self._attr_is_on = new_state
self.async_write_ha_state()

View File

@@ -12,6 +12,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import (
DOMAIN as NUMBER_PLATFORM,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberExtraStoredData,
@@ -107,6 +108,9 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
if description.mode_fn is not None:
self._attr_mode = description.mode_fn(coordinator.device.config[key])
if hasattr(self, "_attr_name") and description.role != ROLE_GENERIC:
delattr(self, "_attr_name")
@property
def native_value(self) -> float | None:
"""Return value of number."""
@@ -181,7 +185,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
("device", "valvePos"): BlockNumberDescription(
key="device|valvepos",
translation_key="valve_position",
name="Valve position",
native_unit_of_measurement=PERCENTAGE,
available=lambda block: cast(int, block.valveError) != 1,
entity_category=EntityCategory.CONFIG,
@@ -200,12 +203,12 @@ RPC_NUMBERS: Final = {
key="blutrv",
sub_key="current_C",
translation_key="external_temperature",
name="External temperature",
native_min_value=-50,
native_max_value=50,
native_step=0.1,
mode=NumberMode.BOX,
entity_category=EntityCategory.CONFIG,
device_class=NumberDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
method="blu_trv_set_external_temperature",
entity_class=RpcBluTrvExtTempNumber,
@@ -213,7 +216,7 @@ RPC_NUMBERS: Final = {
"number_generic": RpcNumberDescription(
key="number",
sub_key="value",
removal_condition=lambda config, _status, key: not is_view_for_platform(
removal_condition=lambda config, _, key: not is_view_for_platform(
config, key, NUMBER_PLATFORM
),
max_fn=lambda config: config["max"],
@@ -229,9 +232,11 @@ RPC_NUMBERS: Final = {
"number_current_limit": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="current_limit",
device_class=NumberDeviceClass.CURRENT,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -241,10 +246,11 @@ RPC_NUMBERS: Final = {
"number_position": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="valve_position",
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -254,10 +260,12 @@ RPC_NUMBERS: Final = {
"number_target_humidity": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="target_humidity",
device_class=NumberDeviceClass.HUMIDITY,
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -267,10 +275,12 @@ RPC_NUMBERS: Final = {
"number_target_temperature": RpcNumberDescription(
key="number",
sub_key="value",
translation_key="target_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
entity_registry_enabled_default=False,
max_fn=lambda config: config["max"],
min_fn=lambda config: config["min"],
mode_fn=lambda config: NumberMode.SLIDER,
mode_fn=lambda _: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -281,21 +291,20 @@ RPC_NUMBERS: Final = {
key="blutrv",
sub_key="pos",
translation_key="valve_position",
name="Valve position",
native_min_value=0,
native_max_value=100,
native_step=1,
mode=NumberMode.SLIDER,
native_unit_of_measurement=PERCENTAGE,
method="blu_trv_set_valve_position",
removal_condition=lambda config, _status, key: config[key].get("enable", True)
removal_condition=lambda config, _, key: config[key].get("enable", True)
is True,
entity_class=RpcBluTrvNumber,
),
"left_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
name="Left slot intensity",
translation_key="left_slot_intensity",
value=lambda status, _: status["left"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -311,7 +320,7 @@ RPC_NUMBERS: Final = {
"right_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
name="Right slot intensity",
translation_key="right_slot_intensity",
value=lambda status, _: status["right"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -402,6 +411,9 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
self.restored_data: NumberExtraStoredData | None = None
super().__init__(coordinator, block, attribute, description, entry)
if hasattr(self, "_attr_name"):
delattr(self, "_attr_name")
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()

View File

@@ -188,6 +188,29 @@
}
}
},
"number": {
"current_limit": {
"name": "Current limit"
},
"external_temperature": {
"name": "External temperature"
},
"left_slot_intensity": {
"name": "Left slot intensity"
},
"right_slot_intensity": {
"name": "Right slot intensity"
},
"target_humidity": {
"name": "Target humidity"
},
"target_temperature": {
"name": "Target temperature"
},
"valve_position": {
"name": "Valve position"
}
},
"select": {
"cury_mode": {
"name": "Mode",

View File

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

View File

@@ -19,13 +19,11 @@ from homeassistant.core import (
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.trigger_template_entity import ValueTemplate
from homeassistant.util.json import JsonValueType
from .const import CONF_QUERY, DOMAIN
from .util import (
async_create_sessionmaker,
check_and_render_sql_query,
convert_value,
generate_lambda_stmt,
redact_credentials,
@@ -39,9 +37,7 @@ _LOGGER = logging.getLogger(__name__)
SERVICE_QUERY = "query"
SERVICE_QUERY_SCHEMA = vol.Schema(
{
vol.Required(CONF_QUERY): vol.All(
cv.template, ValueTemplate.from_template, validate_sql_select
),
vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
vol.Optional(CONF_DB_URL): cv.string,
}
)
@@ -76,9 +72,8 @@ async def _async_query_service(
def _execute_and_convert_query() -> list[JsonValueType]:
"""Execute the query and return the results with converted types."""
sess: Session = sessmaker()
rendered_query = check_and_render_sql_query(call.hass, query_str)
try:
result: Result = sess.execute(generate_lambda_stmt(rendered_query))
result: Result = sess.execute(generate_lambda_stmt(query_str))
except SQLAlchemyError as err:
_LOGGER.debug(
"Error executing query %s: %s",

View File

@@ -18,7 +18,7 @@ import voluptuous as vol
from homeassistant.components.recorder import SupportedDialect, get_instance
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.core import Event, HomeAssistant, async_get_hass, callback
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.template import Template
@@ -46,11 +46,15 @@ def resolve_db_url(hass: HomeAssistant, db_url: str | None) -> str:
return get_instance(hass).db_url
def validate_sql_select(value: Template) -> Template:
def validate_sql_select(value: Template | str) -> Template | str:
"""Validate that value is a SQL SELECT query."""
hass: HomeAssistant
if isinstance(value, str):
hass = async_get_hass()
else:
hass = value.hass # type: ignore[assignment]
try:
assert value.hass
check_and_render_sql_query(value.hass, value)
check_and_render_sql_query(hass, value)
except (TemplateError, InvalidSqlQuery) as err:
raise vol.Invalid(str(err)) from err
return value

View File

@@ -41,5 +41,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.72.1"]
"requirements": ["PySwitchbot==0.73.0"]
}

View File

@@ -84,6 +84,7 @@
"abort": {
"already_configured": "Chat already configured"
},
"entry_type": "Allowed chat ID",
"error": {
"chat_not_found": "Chat not found"
},

View File

@@ -19,9 +19,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
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 EnumTypeData, find_dpcode
from .models import DPCodeEnumWrapper
from .util import get_dpcode
@@ -85,9 +85,21 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category):
entities.extend(
TuyaAlarmEntity(device, manager, description)
TuyaAlarmEntity(
device,
manager,
description,
action_dpcode_wrapper=action_dpcode_wrapper,
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.master_state
),
)
for description in descriptions
if description.key in device.status
if (
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
)
async_add_entities(entities)
@@ -103,7 +115,6 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
_attr_name = None
_attr_code_arm_required = False
_master_state: EnumTypeData | None = None
_alarm_msg_dpcode: DPCode | None = None
def __init__(
@@ -111,33 +122,24 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaAlarmControlPanelEntityDescription,
*,
action_dpcode_wrapper: DPCodeEnumWrapper,
state_dpcode_wrapper: DPCodeEnumWrapper | None,
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._action_dpcode_wrapper = action_dpcode_wrapper
self._state_dpcode_wrapper = state_dpcode_wrapper
# Determine supported modes
if supported_modes := find_dpcode(
self.device, description.key, dptype=DPType.ENUM, prefer_function=True
):
if Mode.HOME in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in supported_modes.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine master state
if enum_type := find_dpcode(
self.device,
description.master_state,
dptype=DPType.ENUM,
prefer_function=True,
):
self._master_state = enum_type
if Mode.HOME in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_HOME
if Mode.ARM in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.ARM_AWAY
if Mode.SOS in action_dpcode_wrapper.type_information.range:
self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER
# Determine alarm message
if dp_code := get_dpcode(self.device, description.alarm_msg):
@@ -149,8 +151,8 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
# When the alarm is triggered, only its 'state' is changing. From 'normal' to 'alarm'.
# The 'mode' doesn't change, and stays as 'arm' or 'home'.
if (
self._master_state is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM
self._state_dpcode_wrapper is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
):
# Only report as triggered if NOT a battery warning
if (
@@ -166,28 +168,26 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
def changed_by(self) -> str | None:
"""Last change triggered by."""
if (
self._master_state is not None
self._state_dpcode_wrapper is not None
and self._alarm_msg_dpcode is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
):
return b64decode(encoded_msg).decode("utf-16be")
return None
def alarm_disarm(self, code: str | None = None) -> None:
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
self._send_command(
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
)
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
def alarm_arm_home(self, code: str | None = None) -> None:
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
def alarm_arm_away(self, code: str | None = None) -> None:
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
def alarm_trigger(self, code: str | None = None) -> None:
async def async_alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)

View File

@@ -15,11 +15,11 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBitmapBitWrapper, DPCodeBooleanWrapper, DPCodeWrapper
@dataclass(frozen=True)
@@ -366,20 +366,48 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
}
def _get_bitmap_bit_mask(
device: CustomerDevice, dpcode: str, bitmap_key: str | None
) -> int | None:
"""Get the bit mask for a given bitmap description."""
if (
bitmap_key is None
or (status_range := device.status_range.get(dpcode)) is None
or status_range.type != DPType.BITMAP
or not isinstance(bitmap_values := json_loads(status_range.values), dict)
or not isinstance(bitmap_labels := bitmap_values.get("label"), list)
or bitmap_key not in bitmap_labels
):
class _CustomDPCodeWrapper(DPCodeWrapper):
"""Custom DPCode Wrapper to check for values in a set."""
_valid_values: set[bool | float | int | str]
def __init__(
self, dpcode: str, valid_values: set[bool | float | int | str]
) -> None:
"""Init CustomDPCodeBooleanWrapper."""
super().__init__(dpcode)
self._valid_values = valid_values
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) is None:
return None
return raw_value in self._valid_values
def _get_dpcode_wrapper(
device: CustomerDevice,
description: TuyaBinarySensorEntityDescription,
) -> DPCodeWrapper | None:
"""Get DPCode wrapper for an entity description."""
dpcode = description.dpcode or description.key
if description.bitmap_key is not None:
return DPCodeBitmapBitWrapper.find_dpcode(
device, dpcode, bitmap_key=description.bitmap_key
)
if bool_type := DPCodeBooleanWrapper.find_dpcode(device, dpcode):
return bool_type
# Legacy / compatibility
if dpcode not in device.status:
return None
return bitmap_labels.index(bitmap_key)
return _CustomDPCodeWrapper(
dpcode,
description.on_value
if isinstance(description.on_value, set)
else {description.on_value},
)
async def async_setup_entry(
@@ -397,25 +425,11 @@ async def async_setup_entry(
for device_id in device_ids:
device = manager.device_map[device_id]
if descriptions := BINARY_SENSORS.get(device.category):
for description in descriptions:
dpcode = description.dpcode or description.key
if dpcode in device.status:
mask = _get_bitmap_bit_mask(
device, dpcode, description.bitmap_key
)
if (
description.bitmap_key is None # Regular binary sensor
or mask is not None # Bitmap sensor with valid mask
):
entities.append(
TuyaBinarySensorEntity(
device,
manager,
description,
mask,
)
)
entities.extend(
TuyaBinarySensorEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
)
async_add_entities(entities)
@@ -436,26 +450,15 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaBinarySensorEntityDescription,
bit_mask: int | None = None,
dpcode_wrapper: DPCodeWrapper,
) -> None:
"""Init Tuya binary sensor."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._bit_mask = bit_mask
self._dpcode_wrapper = dpcode_wrapper
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return true if sensor is on."""
dpcode = self.entity_description.dpcode or self.entity_description.key
if dpcode not in self.device.status:
return False
if self._bit_mask is not None:
# For bitmap sensors, check the specific bit mask
return (self.device.status[dpcode] & (1 << self._bit_mask)) != 0
if isinstance(self.entity_description.on_value, set):
return self.device.status[dpcode] in self.entity_description.on_value
return self.device.status[dpcode] == self.entity_description.on_value
return self._dpcode_wrapper.read_device_status(self.device)

View File

@@ -13,6 +13,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper
BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
DeviceCategory.HXD: (
@@ -21,6 +22,19 @@ BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = {
translation_key="snooze",
),
),
DeviceCategory.MSP: (
ButtonEntityDescription(
key=DPCode.FACTORY_RESET,
translation_key="factory_reset",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
ButtonEntityDescription(
key=DPCode.MANUAL_CLEAN,
translation_key="manual_clean",
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.SD: (
ButtonEntityDescription(
key=DPCode.RESET_DUSTER_CLOTH,
@@ -67,9 +81,13 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := BUTTONS.get(device.category):
entities.extend(
TuyaButtonEntity(device, manager, description)
TuyaButtonEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if description.key in device.status
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
)
async_add_entities(entities)
@@ -89,12 +107,14 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
device: CustomerDevice,
device_manager: Manager,
description: ButtonEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
) -> None:
"""Init Tuya button."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
def press(self) -> None:
async def async_press(self) -> None:
"""Press the button."""
self._send_command([{"code": self.entity_description.key, "value": True}])
await self._async_send_dpcode_update(self._dpcode_wrapper, True)

View File

@@ -704,6 +704,7 @@ class DPCode(StrEnum):
DECIBEL_SWITCH = "decibel_switch"
DEHUMIDITY_SET_ENUM = "dehumidify_set_enum"
DEHUMIDITY_SET_VALUE = "dehumidify_set_value"
DELAY_CLEAN_TIME = "delay_clean_time"
DELAY_SET = "delay_set"
DEW_POINT_TEMP = "dew_point_temp"
DISINFECTION = "disinfection"
@@ -717,6 +718,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 +775,7 @@ class DPCode(StrEnum):
LIQUID_STATE = "liquid_state"
LOCK = "lock" # Lock / Child lock
MACH_OPERATE = "mach_operate"
MANUAL_CLEAN = "manual_clean"
MANUAL_FEED = "manual_feed"
MASTER_MODE = "master_mode" # alarm mode
MASTER_STATE = "master_state" # alarm state

View File

@@ -240,6 +240,13 @@ LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = {
color_data=DPCode.COLOUR_DATA,
),
),
DeviceCategory.MSP: (
TuyaLightEntityDescription(
key=DPCode.LIGHT,
translation_key="light",
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.QJDCZ: (
TuyaLightEntityDescription(
key=DPCode.SWITCH_LED,

View File

@@ -22,17 +22,18 @@ class TypeInformation:
As provided by the SDK, from `device.function` / `device.status_range`.
"""
dpcode: DPCode
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a TypeInformation object."""
raise NotImplementedError("from_json is not implemented for this type")
return cls(dpcode)
@dataclass
class IntegerTypeData(TypeInformation):
"""Integer Type Data."""
dpcode: DPCode
min: int
max: int
scale: float
@@ -100,11 +101,24 @@ class IntegerTypeData(TypeInformation):
)
@dataclass
class BitmapTypeInformation(TypeInformation):
"""Bitmap type information."""
label: list[str]
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json.loads(data)):
return None
return cls(dpcode, **parsed)
@dataclass
class EnumTypeData(TypeInformation):
"""Enum Type Data."""
dpcode: DPCode
range: list[str]
@classmethod
@@ -116,6 +130,8 @@ class EnumTypeData(TypeInformation):
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
DPType.BITMAP: BitmapTypeInformation,
DPType.BOOLEAN: TypeInformation,
DPType.ENUM: EnumTypeData,
DPType.INTEGER: IntegerTypeData,
}
@@ -146,13 +162,13 @@ class DPCodeWrapper(ABC):
The raw device status is converted to a Home Assistant value.
"""
@abstractmethod
def _convert_value_to_raw_value(self, device: CustomerDevice, value: Any) -> Any:
"""Convert a Home Assistant value back to a raw device value.
This is called by `get_update_command` to prepare the value for sending
back to the device, and should be implemented in concrete classes.
back to the device, and should be implemented in concrete classes if needed.
"""
raise NotImplementedError
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
"""Get the update command for the dpcode.
@@ -165,29 +181,6 @@ class DPCodeWrapper(ABC):
}
class DPCodeBooleanWrapper(DPCodeWrapper):
"""Simple wrapper for boolean values.
Supports True/False only.
"""
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) in (True, False):
return raw_value
return None
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: Any
) -> Any | None:
"""Convert a Home Assistant value back to a raw device value."""
if value in (True, False):
return value
# Currently only called with boolean values
# Safety net in case of future changes
raise ValueError(f"Invalid boolean value `{value}`")
class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
"""Base DPCode wrapper with Type Information."""
@@ -203,7 +196,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...],
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
) -> Self | None:
@@ -217,6 +210,31 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
return None
class DPCodeBooleanWrapper(DPCodeTypeInformationWrapper[TypeInformation]):
"""Simple wrapper for boolean values.
Supports True/False only.
"""
DPTYPE = DPType.BOOLEAN
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) in (True, False):
return raw_value
return None
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: Any
) -> Any | None:
"""Convert a Home Assistant value back to a raw device value."""
if value in (True, False):
return value
# Currently only called with boolean values
# Safety net in case of future changes
raise ValueError(f"Invalid boolean value `{value}`")
class DPCodeEnumWrapper(DPCodeTypeInformationWrapper[EnumTypeData]):
"""Simple wrapper for EnumTypeData values."""
@@ -272,6 +290,48 @@ class DPCodeIntegerWrapper(DPCodeTypeInformationWrapper[IntegerTypeData]):
)
class DPCodeBitmapBitWrapper(DPCodeWrapper):
"""Simple wrapper for a specific bit in bitmap values."""
def __init__(self, dpcode: str, mask: int) -> None:
"""Init DPCodeBitmapWrapper."""
super().__init__(dpcode)
self._mask = mask
def read_device_status(self, device: CustomerDevice) -> bool | None:
"""Read the device value for the dpcode."""
if (raw_value := self._read_device_status_raw(device)) is None:
return None
return (raw_value & (1 << self._mask)) != 0
@classmethod
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...],
*,
bitmap_key: str,
) -> Self | None:
"""Find and return a DPCodeBitmapBitWrapper for the given DP codes."""
if (
type_information := find_dpcode(device, dpcodes, dptype=DPType.BITMAP)
) and bitmap_key in type_information.label:
return cls(
type_information.dpcode, type_information.label.index(bitmap_key)
)
return None
@overload
def find_dpcode(
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
*,
prefer_function: bool = False,
dptype: Literal[DPType.BITMAP],
) -> BitmapTypeInformation | None: ...
@overload
def find_dpcode(
device: CustomerDevice,

View File

@@ -180,6 +180,14 @@ NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = {
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.MSP: (
NumberEntityDescription(
key=DPCode.DELAY_CLEAN_TIME,
translation_key="delay_clean_time",
device_class=NumberDeviceClass.DURATION,
entity_category=EntityCategory.CONFIG,
),
),
DeviceCategory.MZJ: (
NumberEntityDescription(
key=DPCode.COOK_TEMPERATURE,

View File

@@ -19,6 +19,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
SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = {
DeviceCategory.CO2BJ: (
@@ -64,9 +65,13 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SIRENS.get(device.category):
entities.extend(
TuyaSirenEntity(device, manager, description)
TuyaSirenEntity(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,21 +94,23 @@ class TuyaSirenEntity(TuyaEntity, SirenEntity):
device: CustomerDevice,
device_manager: Manager,
description: SirenEntityDescription,
dpcode_wrapper: DPCodeBooleanWrapper,
) -> None:
"""Init Tuya Siren."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
@property
def is_on(self) -> bool:
def is_on(self) -> bool | None:
"""Return true if siren is on."""
return self.device.status.get(self.entity_description.key, False)
return self._dpcode_wrapper.read_device_status(self.device)
def turn_on(self, **kwargs: Any) -> None:
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
self._send_command([{"code": self.entity_description.key, "value": True}])
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
def turn_off(self, **kwargs: Any) -> None:
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
self._send_command([{"code": self.entity_description.key, "value": False}])
await self._async_send_dpcode_update(self._dpcode_wrapper, False)

View File

@@ -77,6 +77,12 @@
}
},
"button": {
"factory_reset": {
"name": "Factory reset"
},
"manual_clean": {
"name": "Manual clean"
},
"reset_duster_cloth": {
"name": "Reset duster cloth"
},
@@ -166,6 +172,9 @@
"cook_time": {
"name": "Cooking time"
},
"delay_clean_time": {
"name": "Delay clean time"
},
"down_delay": {
"name": "Down delay"
},

View File

@@ -946,14 +946,13 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SWITCHES.get(device.category):
entities.extend(
TuyaSwitchEntity(
device,
manager,
description,
DPCodeBooleanWrapper(description.key),
)
TuyaSwitchEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if description.key in device.status
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
and _check_deprecation(
hass,
device,

View File

@@ -94,14 +94,13 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := VALVES.get(device.category):
entities.extend(
TuyaValveEntity(
device,
manager,
description,
DPCodeBooleanWrapper(description.key),
)
TuyaValveEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if description.key in device.status
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
)
async_add_entities(entities)

View File

@@ -14,7 +14,7 @@
"velbus-protocol"
],
"quality_scale": "bronze",
"requirements": ["velbus-aio==2025.8.0"],
"requirements": ["velbus-aio==2025.11.0"],
"usb": [
{
"pid": "0B1B",

View File

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

View File

@@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import DOMAIN, SERVICE_UPDATE_DEVS, VS_COORDINATOR, VS_MANAGER
@@ -121,3 +122,21 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
hass.config_entries.async_update_entry(config_entry, minor_version=2)
return True
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
manager = hass.data[DOMAIN][VS_MANAGER]
await manager.get_devices()
for dev in manager.devices:
if isinstance(dev.sub_device_no, int):
device_id = f"{dev.cid}{dev.sub_device_no!s}"
else:
device_id = dev.cid
identifier = next(iter(device_entry.identifiers), None)
if identifier and device_id == identifier[1]:
return False
return True

View File

@@ -22,6 +22,7 @@ from homeassistant.const import (
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -139,6 +140,15 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = (
value_fn=lambda device: device.state.humidity,
exists_fn=is_humidifier,
),
VeSyncSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda device: device.state.temperature,
exists_fn=lambda device: is_humidifier(device)
and device.state.temperature is not None,
),
)

View File

@@ -16,6 +16,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
@@ -65,7 +66,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: VolvoConfigEntry) -> bo
async def _async_auth_and_create_api(
hass: HomeAssistant, entry: VolvoConfigEntry
) -> VolvoCarsApi:
implementation = await async_get_config_entry_implementation(hass, entry)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
oauth_session = OAuth2Session(hass, entry, implementation)
web_session = async_get_clientsession(hass)
auth = VolvoAuth(web_session, oauth_session)

View File

@@ -362,6 +362,9 @@
"no_vehicle": {
"message": "Unable to retrieve vehicle details."
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
},
"unauthorized": {
"message": "Authentication failed. {message}"
},

View File

@@ -54,7 +54,7 @@ _PING_TIMEOUT: Final = 5
_PING_SEND_DELAY: Final = 2
_PIPELINE_FINISH_TIMEOUT: Final = 1
_TTS_SAMPLE_RATE: Final = 22050
_ANNOUNCE_CHUNK_BYTES: Final = 2048 # 1024 samples
_AUDIO_CHUNK_BYTES: Final = 2048 # 1024 samples
_TTS_TIMEOUT_EXTRA: Final = 1.0
# Wyoming stage -> Assist stage
@@ -360,7 +360,7 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
)
assert proc.stdout is not None
while True:
chunk_bytes = await proc.stdout.read(_ANNOUNCE_CHUNK_BYTES)
chunk_bytes = await proc.stdout.read(_AUDIO_CHUNK_BYTES)
if not chunk_bytes:
break
@@ -782,17 +782,22 @@ class WyomingAssistSatellite(WyomingSatelliteEntity, AssistSatelliteEntity):
assert sample_width is not None
assert sample_channels is not None
audio_chunk = AudioChunk(
rate=sample_rate,
width=sample_width,
channels=sample_channels,
audio=data_chunk,
timestamp=timestamp,
)
data_chunk_idx = 0
while data_chunk_idx < len(data_chunk):
audio_chunk = AudioChunk(
rate=sample_rate,
width=sample_width,
channels=sample_channels,
audio=data_chunk[
data_chunk_idx : data_chunk_idx + _AUDIO_CHUNK_BYTES
],
timestamp=timestamp,
)
await self._client.write_event(audio_chunk.event())
timestamp += audio_chunk.milliseconds
total_seconds += audio_chunk.seconds
await self._client.write_event(audio_chunk.event())
timestamp += audio_chunk.milliseconds
total_seconds += audio_chunk.seconds
data_chunk_idx += _AUDIO_CHUNK_BYTES
await self._client.write_event(AudioStop(timestamp=timestamp).event())
_LOGGER.debug("TTS streaming complete")

View File

@@ -99,7 +99,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"request_exception": {
"message": "Failed to connect to Xbox Network"

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast",
"iot_class": "local_push",
"loggers": ["aiomusiccast"],
"requirements": ["aiomusiccast==0.14.8"],
"requirements": ["aiomusiccast==0.15.0"],
"ssdp": [
{
"manufacturer": "Yamaha Corporation"

View File

@@ -134,7 +134,7 @@
"message": "Config entry not found or not loaded!"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation temporarily unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"valve_inoperable_currently": {
"message": "The Valve cannot be operated currently."

View File

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

View File

@@ -115,6 +115,11 @@
"turned_on": "{entity_name} turned on"
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
}
},
"generic": {
"model": "Model",
"ui_managed": "Managed via UI"

16
requirements_all.txt generated
View File

@@ -83,7 +83,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.13.1
# homeassistant.components.switchbot
PySwitchbot==0.72.1
PySwitchbot==0.73.0
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@@ -315,13 +315,13 @@ 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
# homeassistant.components.yamaha_musiccast
aiomusiccast==0.14.8
aiomusiccast==0.15.0
# homeassistant.components.nanoleaf
aionanoleaf==0.2.1
@@ -354,7 +354,7 @@ aiopulse==0.4.6
aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview
aiopvapi==3.2.1
aiopvapi==3.3.0
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2
@@ -1719,7 +1719,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.9.0
plugwise==1.10.0
# homeassistant.components.serial_pm
pmsensor==0.4
@@ -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
@@ -3076,7 +3076,7 @@ vegehub==0.1.26
vehicle==2.2.2
# homeassistant.components.velbus
velbus-aio==2025.8.0
velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21

View File

@@ -21,7 +21,7 @@ pydantic==2.12.2
pylint==4.0.1
pylint-per-file-ignores==1.4.0
pipdeptree==2.26.1
pytest-asyncio==1.2.0
pytest-asyncio==1.3.0
pytest-aiohttp==1.1.0
pytest-cov==7.0.0
pytest-freezer==0.4.9

View File

@@ -80,7 +80,7 @@ PyRMVtransport==0.3.3
PySrDaliGateway==0.13.1
# homeassistant.components.switchbot
PySwitchbot==0.72.1
PySwitchbot==0.73.0
# homeassistant.components.syncthru
PySyncThru==0.8.0
@@ -297,13 +297,13 @@ 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
# homeassistant.components.yamaha_musiccast
aiomusiccast==0.14.8
aiomusiccast==0.15.0
# homeassistant.components.nanoleaf
aionanoleaf==0.2.1
@@ -336,7 +336,7 @@ aiopulse==0.4.6
aiopurpleair==2025.08.1
# homeassistant.components.hunterdouglas_powerview
aiopvapi==3.2.1
aiopvapi==3.3.0
# homeassistant.components.pvpc_hourly_pricing
aiopvpc==4.2.2
@@ -1456,7 +1456,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.9.0
plugwise==1.10.0
# homeassistant.components.poolsense
poolsense==0.0.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
@@ -2543,7 +2543,7 @@ vegehub==0.1.26
vehicle==2.2.2
# homeassistant.components.velbus
velbus-aio==2025.8.0
velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21

View File

@@ -174,6 +174,7 @@ def gen_data_entry_schema(
flow_title: int,
require_step_title: bool,
mandatory_description: str | None = None,
subentry_flow: bool = False,
) -> vol.All:
"""Generate a data entry schema."""
step_title_class = vol.Required if require_step_title else vol.Optional
@@ -206,9 +207,13 @@ def gen_data_entry_schema(
vol.Optional("abort"): {str: translation_value_validator},
vol.Optional("progress"): {str: translation_value_validator},
vol.Optional("create_entry"): {str: translation_value_validator},
vol.Optional("initiate_flow"): {str: translation_value_validator},
vol.Optional("entry_type"): translation_value_validator,
}
if subentry_flow:
schema[vol.Required("entry_type")] = translation_value_validator
schema[vol.Required("initiate_flow")] = {
vol.Required("user"): translation_value_validator,
str: translation_value_validator,
}
if flow_title == REQUIRED:
schema[vol.Required("title")] = translation_value_validator
elif flow_title == REMOVED:
@@ -314,6 +319,7 @@ def gen_strings_schema(config: Config, integration: Integration) -> vol.Schema:
integration=integration,
flow_title=REMOVED,
require_step_title=False,
subentry_flow=True,
),
slug_validator=vol.Any("_", cv.slug),
),

View File

@@ -1,11 +1,11 @@
"""Test Ambient Weather Network sensors."""
from datetime import datetime, timedelta
from datetime import timedelta
from unittest.mock import AsyncMock, patch
from aioambient import OpenAPI
from aioambient.errors import RequestError
from freezegun import freeze_time
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -18,7 +18,7 @@ from .conftest import setup_platform
from tests.common import async_fire_time_changed, snapshot_platform
@freeze_time("2023-11-9")
@pytest.mark.freeze_time("2023-11-9")
@pytest.mark.parametrize(
"config_entry",
["AA:AA:AA:AA:AA:AA", "CC:CC:CC:CC:CC:CC", "DD:DD:DD:DD:DD:DD"],
@@ -54,45 +54,43 @@ async def test_sensors_with_no_data(
@pytest.mark.parametrize("config_entry", ["AA:AA:AA:AA:AA:AA"], indirect=True)
@pytest.mark.freeze_time("2023-11-8")
async def test_sensors_disappearing(
hass: HomeAssistant,
open_api: OpenAPI,
aioambient: AsyncMock,
config_entry: ConfigEntry,
caplog: pytest.LogCaptureFixture,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test that we log errors properly."""
initial_datetime = datetime(year=2023, month=11, day=8)
with freeze_time(initial_datetime) as frozen_datetime:
# Normal state, sensor is available.
await setup_platform(True, hass, config_entry)
# Normal state, sensor is available.
await setup_platform(True, hass, config_entry)
sensor = hass.states.get("sensor.station_a_relative_pressure")
assert sensor is not None
assert float(sensor.state) == pytest.approx(1001.89694313129)
# Sensor becomes unavailable if the network is unavailable. Log message
# should only show up once.
for _ in range(5):
with patch.object(open_api, "get_device_details", side_effect=RequestError()):
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.station_a_relative_pressure")
assert sensor is not None
assert sensor.state == "unavailable"
assert caplog.text.count("Cannot connect to Ambient Network") == 1
# Network comes back. Sensor should start reporting again. Log message
# should only show up once.
for _ in range(5):
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.station_a_relative_pressure")
assert sensor is not None
assert float(sensor.state) == pytest.approx(1001.89694313129)
# Sensor becomes unavailable if the network is unavailable. Log message
# should only show up once.
for _ in range(5):
with patch.object(
open_api, "get_device_details", side_effect=RequestError()
):
frozen_datetime.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.station_a_relative_pressure")
assert sensor is not None
assert sensor.state == "unavailable"
assert caplog.text.count("Cannot connect to Ambient Network") == 1
# Network comes back. Sensor should start reporting again. Log message
# should only show up once.
for _ in range(5):
frozen_datetime.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
sensor = hass.states.get("sensor.station_a_relative_pressure")
assert sensor is not None
assert float(sensor.state) == pytest.approx(1001.89694313129)
assert caplog.text.count("Fetching ambient_network data recovered") == 1
assert caplog.text.count("Fetching ambient_network data recovered") == 1

View File

@@ -1 +1,168 @@
"""Tests for the Anthropic integration."""
from anthropic.types import (
CitationsDelta,
InputJSONDelta,
RawContentBlockDeltaEvent,
RawContentBlockStartEvent,
RawContentBlockStopEvent,
RawMessageStreamEvent,
RedactedThinkingBlock,
ServerToolUseBlock,
SignatureDelta,
TextBlock,
TextCitation,
TextDelta,
ThinkingBlock,
ThinkingDelta,
ToolUseBlock,
WebSearchResultBlock,
WebSearchToolResultBlock,
)
def create_content_block(
index: int, text_parts: list[str], citations: list[TextCitation] | None = None
) -> list[RawMessageStreamEvent]:
"""Create a text content block with the specified deltas."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=TextBlock(
text="", type="text", citations=[] if citations else None
),
index=index,
),
*[
RawContentBlockDeltaEvent(
delta=CitationsDelta(citation=citation, type="citations_delta"),
index=index,
type="content_block_delta",
)
for citation in (citations or [])
],
*[
RawContentBlockDeltaEvent(
delta=TextDelta(text=text_part, type="text_delta"),
index=index,
type="content_block_delta",
)
for text_part in text_parts
],
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_thinking_block(
index: int, thinking_parts: list[str]
) -> list[RawMessageStreamEvent]:
"""Create a thinking block with the specified deltas."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=ThinkingBlock(signature="", thinking="", type="thinking"),
index=index,
),
*[
RawContentBlockDeltaEvent(
delta=ThinkingDelta(thinking=thinking_part, type="thinking_delta"),
index=index,
type="content_block_delta",
)
for thinking_part in thinking_parts
],
RawContentBlockDeltaEvent(
delta=SignatureDelta(
signature="ErUBCkYIARgCIkCYXaVNJShe3A86Hp7XUzh9YsCYBbJTbQsrklTAPtJ2sP/N"
"oB6tSzpK/nTL6CjSo2R6n0KNBIg5MH6asM2R/kmaEgyB/X1FtZq5OQAC7jUaDEPWCdcwGQ"
"4RaBy5wiIwmRxExIlDhoY6tILoVPnOExkC/0igZxHEwxK8RU/fmw0b+o+TwAarzUitwzbo"
"21E5Kh3pa3I6yqVROf1t2F8rFocNUeCegsWV/ytwYV+ayA==",
type="signature_delta",
),
index=index,
type="content_block_delta",
),
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_redacted_thinking_block(index: int) -> list[RawMessageStreamEvent]:
"""Create a redacted thinking block."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=RedactedThinkingBlock(
data="EroBCkYIARgCKkBJDytPJhw//4vy3t7aE+LfIkxvkAh51cBPrAvBCo6AjgI57Zt9K"
"WPnUVV50OQJ0KZzUFoGZG5sxg95zx4qMwkoEgz43Su3myJKckvj03waDBZLIBSeoAeRUeV"
"sJCIwQ5edQN0sa+HNeB/KUBkoMUwV+IT0eIhcpFxnILdvxUAKM4R1o4KG3x+yO0eo/kyOK"
"iKfrCPFQhvBVmTZPFhgA2Ow8L9gGDVipcz6x3Uu9YETGEny",
type="redacted_thinking",
),
index=index,
),
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_tool_use_block(
index: int, tool_id: str, tool_name: str, json_parts: list[str]
) -> list[RawMessageStreamEvent]:
"""Create a tool use content block with the specified deltas."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=ToolUseBlock(
id=tool_id, name=tool_name, input={}, type="tool_use"
),
index=index,
),
*[
RawContentBlockDeltaEvent(
delta=InputJSONDelta(partial_json=json_part, type="input_json_delta"),
index=index,
type="content_block_delta",
)
for json_part in json_parts
],
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_web_search_block(
index: int, id: str, query_parts: list[str]
) -> list[RawMessageStreamEvent]:
"""Create a server tool use block for web search."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=ServerToolUseBlock(
type="server_tool_use", id=id, input={}, name="web_search"
),
index=index,
),
*[
RawContentBlockDeltaEvent(
delta=InputJSONDelta(type="input_json_delta", partial_json=query_part),
index=index,
type="content_block_delta",
)
for query_part in query_parts
],
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]
def create_web_search_result_block(
index: int, id: str, results: list[WebSearchResultBlock]
) -> list[RawMessageStreamEvent]:
"""Create a server tool result block for web search results."""
return [
RawContentBlockStartEvent(
type="content_block_start",
content_block=WebSearchToolResultBlock(
type="web_search_tool_result", tool_use_id=id, content=results
),
index=index,
),
RawContentBlockStopEvent(index=index, type="content_block_stop"),
]

View File

@@ -1,8 +1,20 @@
"""Tests helpers."""
from collections.abc import AsyncGenerator
from unittest.mock import patch
from collections.abc import AsyncGenerator, Generator, Iterable
from unittest.mock import AsyncMock, patch
from anthropic.types import (
Message,
MessageDeltaUsage,
RawContentBlockStartEvent,
RawMessageDeltaEvent,
RawMessageStartEvent,
RawMessageStopEvent,
RawMessageStreamEvent,
ToolUseBlock,
Usage,
)
from anthropic.types.raw_message_delta_event import Delta
import pytest
from homeassistant.components.anthropic import CONF_CHAT_MODEL
@@ -14,6 +26,7 @@ from homeassistant.components.anthropic.const import (
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
)
from homeassistant.const import CONF_LLM_HASS_API
@@ -40,7 +53,13 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"subentry_type": "conversation",
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"data": {},
"subentry_type": "ai_task_data",
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
],
)
entry.add_to_hass(hass)
@@ -114,3 +133,61 @@ async def mock_init_component(
async def setup_ha(hass: HomeAssistant) -> None:
"""Set up Home Assistant."""
assert await async_setup_component(hass, "homeassistant", {})
@pytest.fixture
def mock_create_stream() -> Generator[AsyncMock]:
"""Mock stream response."""
async def mock_generator(events: Iterable[RawMessageStreamEvent], **kwargs):
"""Create a stream of messages with the specified content blocks."""
stop_reason = "end_turn"
refusal_magic_string = "ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86"
for message in kwargs.get("messages"):
if message["role"] != "user":
continue
if isinstance(message["content"], str):
if refusal_magic_string in message["content"]:
stop_reason = "refusal"
break
else:
for content in message["content"]:
if content.get(
"type"
) == "text" and refusal_magic_string in content.get("text", ""):
stop_reason = "refusal"
break
yield RawMessageStartEvent(
message=Message(
type="message",
id="msg_1234567890ABCDEFGHIJKLMN",
content=[],
role="assistant",
model="claude-3-5-sonnet-20240620",
usage=Usage(input_tokens=0, output_tokens=0),
),
type="message_start",
)
for event in events:
if isinstance(event, RawContentBlockStartEvent) and isinstance(
event.content_block, ToolUseBlock
):
stop_reason = "tool_use"
yield event
yield RawMessageDeltaEvent(
type="message_delta",
delta=Delta(stop_reason=stop_reason, stop_sequence=""),
usage=MessageDeltaUsage(output_tokens=0),
)
yield RawMessageStopEvent(type="message_stop")
with patch(
"anthropic.resources.messages.AsyncMessages.create",
new_callable=AsyncMock,
) as mock_create:
mock_create.side_effect = lambda **kwargs: mock_generator(
mock_create.return_value.pop(0), **kwargs
)
yield mock_create

View File

@@ -0,0 +1,211 @@
"""Tests for the Anthropic integration."""
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
import voluptuous as vol
from homeassistant.components import ai_task, media_source
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er, selector
from . import create_content_block, create_tool_use_block
from tests.common import MockConfigEntry
async def test_generate_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task data generation."""
entity_id = "ai_task.claude_ai_task"
# Ensure entity is linked to the subentry
entity_entry = entity_registry.async_get(entity_id)
ai_task_entry = next(
iter(
entry
for entry in mock_config_entry.subentries.values()
if entry.subentry_type == "ai_task_data"
)
)
assert entity_entry is not None
assert entity_entry.config_entry_id == mock_config_entry.entry_id
assert entity_entry.config_subentry_id == ai_task_entry.subentry_id
mock_create_stream.return_value = [create_content_block(0, ["The test data"])]
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Generate test data",
)
assert result.data == "The test data"
async def test_generate_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test AI Task structured data generation."""
mock_create_stream.return_value = [
create_tool_use_block(
1,
"toolu_0123456789AbCdEfGhIjKlM",
"test_task",
['{"charac', 'ters": ["Mario', '", "Luigi"]}'],
),
]
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.claude_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
assert result.data == {"characters": ["Mario", "Luigi"]}
async def test_generate_invalid_structured_data(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
) -> None:
"""Test AI Task with invalid JSON response."""
mock_create_stream.return_value = [
create_tool_use_block(
1,
"toolu_0123456789AbCdEfGhIjKlM",
"test_task",
"INVALID JSON RESPONSE",
)
]
with pytest.raises(
HomeAssistantError, match="Error with Claude structured response"
):
await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id="ai_task.claude_ai_task",
instructions="Generate test data",
structure=vol.Schema(
{
vol.Required("characters"): selector.selector(
{
"text": {
"multiple": True,
}
}
)
},
),
)
async def test_generate_data_with_attachments(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
mock_create_stream: AsyncMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test AI Task data generation with attachments."""
entity_id = "ai_task.claude_ai_task"
mock_create_stream.return_value = [create_content_block(0, ["Hi there!"])]
# Test with attachments
with (
patch(
"homeassistant.components.media_source.async_resolve_media",
side_effect=[
media_source.PlayMedia(
url="http://example.com/doorbell_snapshot.jpg",
mime_type="image/jpeg",
path=Path("doorbell_snapshot.jpg"),
),
media_source.PlayMedia(
url="http://example.com/context.pdf",
mime_type="application/pdf",
path=Path("context.pdf"),
),
],
),
patch("pathlib.Path.exists", return_value=True),
patch(
"homeassistant.components.openai_conversation.entity.guess_file_type",
return_value=("image/jpeg", None),
),
patch("pathlib.Path.read_bytes", return_value=b"fake_image_data"),
):
result = await ai_task.async_generate_data(
hass,
task_name="Test Task",
entity_id=entity_id,
instructions="Test prompt",
attachments=[
{"media_content_id": "media-source://media/doorbell_snapshot.jpg"},
{"media_content_id": "media-source://media/context.pdf"},
],
)
assert result.data == "Hi there!"
# Verify that the create stream was called with the correct parameters
# The last call should have the user message with attachments
call_args = mock_create_stream.call_args
assert call_args is not None
# Check that the input includes the attachments
input_messages = call_args[1]["messages"]
assert len(input_messages) > 0
# Find the user message with attachments
user_message_with_attachments = input_messages[-2]
assert user_message_with_attachments is not None
assert isinstance(user_message_with_attachments["content"], list)
assert len(user_message_with_attachments["content"]) == 3 # Text + attachments
assert user_message_with_attachments["content"] == [
{"type": "text", "text": "Test prompt"},
{
"type": "image",
"source": {
"data": "ZmFrZV9pbWFnZV9kYXRh",
"media_type": "image/jpeg",
"type": "base64",
},
},
{
"type": "document",
"source": {
"data": "ZmFrZV9pbWFnZV9kYXRh",
"media_type": "application/pdf",
"type": "base64",
},
},
]

View File

@@ -15,7 +15,10 @@ from httpx import URL, Request, Response
import pytest
from homeassistant import config_entries
from homeassistant.components.anthropic.config_flow import RECOMMENDED_OPTIONS
from homeassistant.components.anthropic.config_flow import (
RECOMMENDED_AI_TASK_OPTIONS,
RECOMMENDED_CONVERSATION_OPTIONS,
)
from homeassistant.components.anthropic.const import (
CONF_CHAT_MODEL,
CONF_MAX_TOKENS,
@@ -30,6 +33,7 @@ from homeassistant.components.anthropic.const import (
CONF_WEB_SEARCH_REGION,
CONF_WEB_SEARCH_TIMEZONE,
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DOMAIN,
RECOMMENDED_CHAT_MODEL,
@@ -74,7 +78,6 @@ async def test_form(hass: HomeAssistant) -> None:
"api_key": "bla",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["data"] == {
@@ -84,10 +87,16 @@ async def test_form(hass: HomeAssistant) -> None:
assert result2["subentries"] == [
{
"subentry_type": "conversation",
"data": RECOMMENDED_OPTIONS,
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"title": DEFAULT_CONVERSATION_NAME,
"unique_id": None,
}
},
{
"subentry_type": "ai_task_data",
"data": RECOMMENDED_AI_TASK_OPTIONS,
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
]
assert len(mock_setup_entry.mock_calls) == 1
@@ -135,14 +144,13 @@ async def test_creating_conversation_subentry(
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{CONF_NAME: "Mock name", **RECOMMENDED_OPTIONS},
{CONF_NAME: "Mock name", **RECOMMENDED_CONVERSATION_OPTIONS},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Mock name"
processed_options = RECOMMENDED_OPTIONS.copy()
processed_options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
processed_options[CONF_PROMPT] = processed_options[CONF_PROMPT].strip()
assert result2["data"] == processed_options
@@ -302,7 +310,6 @@ async def test_subentry_web_search_user_location(
"user_location": True,
},
)
await hass.async_block_till_done()
assert (
mock_create.call_args.kwargs["messages"][0]["content"] == "Where are the "
@@ -557,3 +564,122 @@ async def test_subentry_options_switching(
assert subentry_flow["type"] is FlowResultType.ABORT
assert subentry_flow["reason"] == "reconfigure_successful"
assert subentry.data == expected_options
async def test_creating_ai_task_subentry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test creating an AI task subentry."""
old_subentries = set(mock_config_entry.subentries)
# Original conversation + original ai_task
assert len(mock_config_entry.subentries) == 2
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "init"
assert not result.get("errors")
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
"name": "Custom AI Task",
CONF_RECOMMENDED: True,
},
)
assert result2.get("type") is FlowResultType.CREATE_ENTRY
assert result2.get("title") == "Custom AI Task"
assert result2.get("data") == {
CONF_RECOMMENDED: True,
}
assert (
len(mock_config_entry.subentries) == 3
) # Original conversation + original ai_task + new ai_task
new_subentry_id = list(set(mock_config_entry.subentries) - old_subentries)[0]
new_subentry = mock_config_entry.subentries[new_subentry_id]
assert new_subentry.subentry_type == "ai_task_data"
assert new_subentry.title == "Custom AI Task"
async def test_ai_task_subentry_not_loaded(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test creating an AI task subentry when entry is not loaded."""
# Don't call mock_init_component to simulate not loaded state
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "entry_not_loaded"
async def test_creating_ai_task_subentry_advanced(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_init_component,
) -> None:
"""Test creating an AI task subentry with advanced settings."""
result = await hass.config_entries.subentries.async_init(
(mock_config_entry.entry_id, "ai_task_data"),
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "init"
# Go to advanced settings
result2 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
"name": "Advanced AI Task",
CONF_RECOMMENDED: False,
},
)
assert result2.get("type") is FlowResultType.FORM
assert result2.get("step_id") == "advanced"
# Configure advanced settings
result3 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_MAX_TOKENS: 200,
CONF_TEMPERATURE: 0.5,
},
)
assert result3.get("type") is FlowResultType.FORM
assert result3.get("step_id") == "model"
# Configure model settings
result4 = await hass.config_entries.subentries.async_configure(
result["flow_id"],
{
CONF_WEB_SEARCH: False,
},
)
assert result4.get("type") is FlowResultType.CREATE_ENTRY
assert result4.get("title") == "Advanced AI Task"
assert result4.get("data") == {
CONF_RECOMMENDED: False,
CONF_CHAT_MODEL: "claude-sonnet-4-5",
CONF_MAX_TOKENS: 200,
CONF_TEMPERATURE: 0.5,
CONF_WEB_SEARCH: False,
CONF_WEB_SEARCH_MAX_USES: 5,
CONF_WEB_SEARCH_USER_LOCATION: False,
CONF_THINKING_BUDGET: 0,
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,6 @@
from datetime import datetime
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from homeassistant.components import (
@@ -453,7 +452,7 @@ async def test_todo_add_item_fr(
assert intent_obj.slots.get("item", {}).get("value", "").strip() == "farine"
@freeze_time(
@pytest.mark.freeze_time(
datetime(
year=2013,
month=9,

View File

@@ -144,7 +144,7 @@ async def test_custom_agent(
@pytest.mark.usefixtures("init_components")
async def test_prepare_reload(hass: HomeAssistant) -> None:
async def test_reload(hass: HomeAssistant) -> None:
"""Test calling the reload service."""
language = hass.config.language
agent = async_get_agent(hass)
@@ -154,20 +154,39 @@ async def test_prepare_reload(hass: HomeAssistant) -> None:
# Confirm intents are loaded
assert agent._lang_intents.get(language)
# Confirm config intents are empty
assert not agent._config_intents["intents"]
# Try to clear for a different language
await hass.services.async_call("conversation", "reload", {"language": "elvish"})
await hass.async_block_till_done()
await hass.services.async_call(
"conversation", "reload", {"language": "elvish"}, blocking=True
)
# Confirm intents are still loaded
assert agent._lang_intents.get(language)
# Confirm config intents are still empty
assert not agent._config_intents["intents"]
# Clear cache for all languages
await hass.services.async_call("conversation", "reload", {})
await hass.async_block_till_done()
# Reload from a changed configuration file
hass_config_new = {
"conversation": {
"intents": {
"TestIntent": [
"Test intent phrase",
"Another test intent phrase",
]
}
}
}
with patch(
"homeassistant.config.load_yaml_config_file", return_value=hass_config_new
):
await hass.services.async_call("conversation", "reload", {}, blocking=True)
# Confirm intent cache is cleared
assert not agent._lang_intents.get(language)
# Confirm new config intents are loaded
assert agent._config_intents["intents"]
@pytest.mark.usefixtures("init_components")

View File

@@ -4,17 +4,14 @@ from collections.abc import Callable
import time
from typing import Any
from aiohttp.test_utils import TestClient
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.auth.models import Credentials
from homeassistant.core import HomeAssistant
from .conftest import TEST_EVENT, ApiResult, ComponentSetup
from tests.common import CLIENT_ID, MockConfigEntry, MockUser
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@@ -29,41 +26,13 @@ def mock_test_setup(
mock_calendars_list({"items": [test_api_calendar]})
async def generate_new_hass_access_token(
hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials
) -> str:
"""Return an access token to access Home Assistant."""
await hass.auth.async_link_user(hass_admin_user, hass_admin_credential)
refresh_token = await hass.auth.async_create_refresh_token(
hass_admin_user, CLIENT_ID, credential=hass_admin_credential
)
return hass.auth.async_create_access_token(refresh_token)
def _get_test_client_generator(
hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str
):
"""Return a test client generator.""."""
async def auth_client() -> TestClient:
return await aiohttp_client(
hass.http.app, headers={"Authorization": f"Bearer {new_token}"}
)
return auth_client
@freeze_time("2023-03-13 12:05:00-07:00")
@pytest.mark.usefixtures("socket_enabled")
@pytest.mark.freeze_time("2023-03-13 12:05:00-07:00")
async def test_diagnostics(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
component_setup: ComponentSetup,
mock_events_list_items: Callable[[list[dict[str, Any]]], None],
hass_admin_user: MockUser,
hass_admin_credential: Credentials,
config_entry: MockConfigEntry,
aiohttp_client: ClientSessionGenerator,
snapshot: SnapshotAssertion,
aioclient_mock: AiohttpClientMocker,
) -> None:
@@ -103,13 +72,5 @@ async def test_diagnostics(
assert await component_setup()
# Since we are freezing time only when we enter this test, we need to
# manually create a new token and clients since the token created by
# the fixtures would not be valid.
new_token = await generate_new_hass_access_token(
hass, hass_admin_user, hass_admin_credential
)
data = await get_diagnostics_for_config_entry(
hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry
)
data = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert data == snapshot

View File

@@ -3,7 +3,6 @@
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time
from google.genai.types import File, FileState, GenerateContentResponse
import pytest
import voluptuous as vol
@@ -223,7 +222,7 @@ async def test_generate_data(
@pytest.mark.usefixtures("mock_init_component")
@freeze_time("2025-06-14 22:59:00")
@pytest.mark.freeze_time("2025-06-14 22:59:00")
async def test_generate_image(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,

View File

@@ -3,7 +3,6 @@
from collections.abc import Generator
from unittest.mock import patch
from freezegun.api import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -33,7 +32,7 @@ async def set_tz(hass: HomeAssistant) -> None:
@pytest.mark.usefixtures("habitica")
@freeze_time("2024-09-20T22:00:00.000Z")
@pytest.mark.freeze_time("2024-09-20T22:00:00.000Z")
async def test_calendar_platform(
hass: HomeAssistant,
config_entry: MockConfigEntry,

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from freezegun.api import FrozenDateTimeFactory, freeze_time
from freezegun.api import FrozenDateTimeFactory
from habiticalib import HabiticaGroupMembersResponse
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -82,7 +82,7 @@ async def test_notify_platform(
),
],
)
@freeze_time("2025-08-13T00:00:00+00:00")
@pytest.mark.freeze_time("2025-08-13T00:00:00+00:00")
async def test_send_message(
hass: HomeAssistant,
config_entry: MockConfigEntry,

View File

@@ -7,7 +7,6 @@ from unittest.mock import AsyncMock, patch
from uuid import UUID
from aiohttp import ClientError
from freezegun.api import freeze_time
from habiticalib import (
Checklist,
Direction,
@@ -1845,7 +1844,7 @@ async def test_create_todo(
],
)
@pytest.mark.usefixtures("mock_uuid4")
@freeze_time("2025-02-25T22:00:00.000Z")
@pytest.mark.freeze_time("2025-02-25T22:00:00.000Z")
async def test_update_daily(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -2023,7 +2022,7 @@ async def test_update_daily(
],
)
@pytest.mark.usefixtures("mock_uuid4")
@freeze_time("2025-02-25T22:00:00.000Z")
@pytest.mark.freeze_time("2025-02-25T22:00:00.000Z")
async def test_create_daily(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@@ -2064,7 +2063,7 @@ async def test_create_daily(
],
)
@pytest.mark.usefixtures("mock_uuid4")
@freeze_time("2025-02-25T22:00:00.000Z")
@pytest.mark.freeze_time("2025-02-25T22:00:00.000Z")
async def test_update_daily_service_validation_errors(
hass: HomeAssistant,
config_entry: MockConfigEntry,

View File

@@ -2,7 +2,6 @@
from unittest.mock import patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -15,7 +14,7 @@ from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@freeze_time("2021-01-01T12:00:00Z")
@pytest.mark.freeze_time("2021-01-01T12:00:00Z")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_sensor_entities(
hass: HomeAssistant,

View File

@@ -2,7 +2,6 @@
from unittest.mock import AsyncMock, patch
from freezegun import freeze_time
import pytest
from syrupy.assertion import SnapshotAssertion
@@ -15,7 +14,7 @@ from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
@freeze_time("2021-01-01T12:00:00Z")
@pytest.mark.freeze_time("2021-01-01T12:00:00Z")
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_sensor_entities(
hass: HomeAssistant,

Some files were not shown because too many files have changed in this diff Show More