Compare commits

..

1 Commits

Author SHA1 Message Date
copilot-swe-agent[bot] c68b0dcb7d Initial plan 2025-11-10 11:21:26 +00:00
252 changed files with 6315 additions and 14615 deletions
+1 -1
View File
@@ -622,7 +622,7 @@ jobs:
steps:
- *checkout
- name: Dependency review
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
with:
license-check: false # We use our own license audit checks
Generated
+2 -2
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 @arturpragacz
/tests/components/music_assistant/ @music-assistant @arturpragacz
/homeassistant/components/music_assistant/ @music-assistant
/tests/components/music_assistant/ @music-assistant
/homeassistant/components/mutesync/ @currentoor
/tests/components/mutesync/ @currentoor
/homeassistant/components/my/ @home-assistant/core
@@ -25,7 +25,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
)
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
PLATFORMS = (Platform.CONVERSATION,)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
@@ -1,80 +0,0 @@
"""AI Task integration for Anthropic."""
from __future__ import annotations
from json import JSONDecodeError
import logging
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
from .entity import AnthropicBaseLLMEntity
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up AI Task entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "ai_task_data":
continue
async_add_entities(
[AnthropicTaskEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class AnthropicTaskEntity(
ai_task.AITaskEntity,
AnthropicBaseLLMEntity,
):
"""Anthropic AI Task entity."""
_attr_supported_features = (
ai_task.AITaskEntityFeature.GENERATE_DATA
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
)
async def _async_generate_data(
self,
task: ai_task.GenDataTask,
chat_log: conversation.ChatLog,
) -> ai_task.GenDataTaskResult:
"""Handle a generate data task."""
await self._async_handle_chat_log(chat_log, task.name, task.structure)
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
raise HomeAssistantError(
"Last content in chat log is not an AssistantContent"
)
text = chat_log.content[-1].content or ""
if not task.structure:
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=text,
)
try:
data = json_loads(text)
except JSONDecodeError as err:
_LOGGER.error(
"Failed to parse JSON response: %s. Response: %s",
err,
text,
)
raise HomeAssistantError("Error with Claude structured response") from err
return ai_task.GenDataTaskResult(
conversation_id=chat_log.conversation_id,
data=data,
)
@@ -53,7 +53,6 @@ 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,
@@ -75,16 +74,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
RECOMMENDED_CONVERSATION_OPTIONS = {
RECOMMENDED_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.
@@ -107,7 +102,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
errors = {}
if user_input is not None:
self._async_abort_entries_match(user_input)
@@ -135,16 +130,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
subentries=[
{
"subentry_type": "conversation",
"data": RECOMMENDED_CONVERSATION_OPTIONS,
"data": RECOMMENDED_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,
},
}
],
)
@@ -158,10 +147,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this integration."""
return {
"conversation": ConversationSubentryFlowHandler,
"ai_task_data": ConversationSubentryFlowHandler,
}
return {"conversation": ConversationSubentryFlowHandler}
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
@@ -178,10 +164,7 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a subentry."""
if self._subentry_type == "ai_task_data":
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
else:
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
self.options = RECOMMENDED_OPTIONS.copy()
return await self.async_step_init()
async def async_step_reconfigure(
@@ -215,29 +198,23 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
errors: dict[str, str] = {}
if self._is_new:
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[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = (
str
)
step_schema[
vol.Required(
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
)
] = bool
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,
}
)
if user_input is not None:
if not user_input.get(CONF_LLM_HASS_API):
@@ -321,14 +298,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
if not model.startswith(tuple(NON_THINKING_MODELS)):
step_schema[
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
] = vol.All(
NumberSelector(
NumberSelectorConfig(
min=0,
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
)
),
vol.Coerce(int),
] = NumberSelector(
NumberSelectorConfig(
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
)
)
else:
self.options.pop(CONF_THINKING_BUDGET, None)
@@ -6,7 +6,6 @@ DOMAIN = "anthropic"
LOGGER = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "Claude conversation"
DEFAULT_AI_TASK_NAME = "Claude AI Task"
CONF_RECOMMENDED = "recommended"
CONF_PROMPT = "prompt"
+2 -168
View File
@@ -1,24 +1,17 @@
"""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,
@@ -44,9 +37,6 @@ from anthropic.types import (
ThinkingConfigDisabledParam,
ThinkingConfigEnabledParam,
ThinkingDelta,
ToolChoiceAnyParam,
ToolChoiceAutoParam,
ToolChoiceToolParam,
ToolParam,
ToolResultBlockParam,
ToolUnionParam,
@@ -60,16 +50,13 @@ 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 (
@@ -334,7 +321,6 @@ 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
]:
@@ -395,16 +381,6 @@ 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
@@ -495,16 +471,7 @@ 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):
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
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}
@@ -523,9 +490,6 @@ 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 {
@@ -593,8 +557,6 @@ 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
@@ -651,74 +613,6 @@ 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
@@ -735,11 +629,7 @@ class AnthropicBaseLLMEntity(Entity):
content
async for content in chat_log.async_add_delta_content_stream(
self.entity_id,
_transform_stream(
chat_log,
stream,
output_tool=structure_name if structure else None,
),
_transform_stream(chat_log, stream),
)
]
)
@@ -751,59 +641,3 @@ class AnthropicBaseLLMEntity(Entity):
if not chat_log.unresponded_tool_results:
break
async def async_prepare_files_for_prompt(
hass: HomeAssistant, files: list[tuple[Path, str | None]]
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
"""Append files to a prompt.
Caller needs to ensure that the files are allowed.
"""
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
content: list[ImageBlockParam | DocumentBlockParam] = []
for file_path, mime_type in files:
if not file_path.exists():
raise HomeAssistantError(f"`{file_path}` does not exist")
if mime_type is None:
mime_type = guess_file_type(file_path)[0]
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
raise HomeAssistantError(
"Only images and PDF are supported by the Anthropic API,"
f"`{file_path}` is not an image file or PDF"
)
if mime_type == "image/jpg":
mime_type = "image/jpeg"
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
if mime_type.startswith("image/"):
content.append(
ImageBlockParam(
type="image",
source=Base64ImageSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
elif mime_type.startswith("application/pdf"):
content.append(
DocumentBlockParam(
type="document",
source=Base64PDFSourceParam(
type="base64",
media_type=mime_type, # type: ignore[typeddict-item]
data=base64_file,
),
)
)
return content
return await hass.async_add_executor_job(append_files_to_content)
@@ -18,49 +18,6 @@
}
},
"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.",
@@ -89,8 +46,7 @@
},
"data_description": {
"prompt": "Instruct how the LLM should respond. This can be a template."
},
"title": "Basic settings"
}
},
"model": {
"data": {
+32 -44
View File
@@ -37,6 +37,13 @@ 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]:
@@ -168,55 +175,36 @@ 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] = {}
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,
}
)
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}
)
return self.async_show_form(
step_id="reconfigure",
data_schema=schema,
data_schema=STEP_RECONFIGURE,
errors=errors,
)
@@ -4,17 +4,11 @@ from __future__ import annotations
from collections.abc import Callable
import logging
from pathlib import Path
from typing import Any, Literal
from typing import Literal
from hassil.intents import Intents, WildcardSlotList
from hassil.recognize import RecognizeResult
from hassil.util import merge_dict
from home_assistant_intents import get_intents, get_languages
import voluptuous as vol
import yaml
from homeassistant.components.homeassistant.exposed_entities import async_should_expose
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL
from homeassistant.core import (
@@ -25,18 +19,10 @@ from homeassistant.core import (
callback,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
area_registry as ar,
config_validation as cv,
entity_registry as er,
floor_registry as fr,
intent,
)
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
from homeassistant.util import language as language_util
from .agent_manager import (
AgentInfo,
@@ -63,19 +49,14 @@ from .const import (
ATTR_CONVERSATION_ID,
ATTR_LANGUAGE,
ATTR_TEXT,
CUSTOM_SENTENCES_DIR_NAME,
DATA_COMPONENT,
DOMAIN,
HOME_ASSISTANT_AGENT,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
SENTENCE_TRIGGER_INTENT_NAME,
SERVICE_PROCESS,
SERVICE_RELOAD,
ConversationEntityFeature,
IntentSource,
)
from .default_agent import async_setup_default_agent, collect_list_references
from .default_agent import async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
@@ -280,133 +261,15 @@ async def async_handle_intents(
return await agent.async_handle_intents(user_input, intent_filter=intent_filter)
async def async_get_intents(
hass: HomeAssistant, language: str, source: IntentSource = IntentSource.ALL
) -> Intents:
"""Load intents for a language."""
intents_dict: dict[str, Any] = {"intents": {}, "lists": {}}
agent = get_agent_manager(hass).default_agent
assert agent is not None
if source & IntentSource.BUILTIN_SENTENCES:
# From home-assistant-intents package
supported_langs = set(get_languages())
lang_matches = language_util.matches(language, supported_langs)
if lang_matches and (lang_intents := get_intents(lang_matches[0])):
intents_dict = lang_intents
if source & IntentSource.CUSTOM_SENTENCES:
# From config/custom_sentences
def load_custom_sentences():
"""Merge custom sentences for matching language only."""
base_dir = Path(hass.config.path(CUSTOM_SENTENCES_DIR_NAME))
supported_langs = {d.name for d in base_dir.iterdir() if d.is_dir()}
lang_matches = language_util.matches(language, supported_langs)
if not lang_matches:
return
sentences_dir = base_dir / lang_matches[0]
for sentences_path in sentences_dir.rglob("*.yaml"):
with open(sentences_path, encoding="utf-8") as sentences_file:
merge_dict(intents_dict, yaml.safe_load(sentences_file))
# Do I/O in executor
await hass.async_add_executor_job(load_custom_sentences)
if (source & IntentSource.CONVERSATION_CONFIG) and agent.config_intents:
# From conversation YAML config
merge_dict(intents_dict, agent.config_intents)
if (source & IntentSource.SENTENCE_TRIGGERS) and agent.trigger_details:
# From automations
merge_dict(
intents_dict,
{
"intents": {
SENTENCE_TRIGGER_INTENT_NAME: {
"data": [
{"sentences": trigger_details.sentences}
for trigger_details in agent.trigger_details
]
}
}
},
)
# Add exposed entities + areas/floors
entity_tuples = []
entity_registry = er.async_get(hass)
for state in hass.states.async_all():
if not async_should_expose(hass, DOMAIN, state.entity_id):
continue
context: dict[str, Any] = {"domain": state.domain}
entity_tuples.append((state.name, context))
if not (entity := entity_registry.async_get(state.entity_id)):
continue
entity_tuples.extend(
(alias, context)
for alias in filter(None, (a.strip() for a in entity.aliases))
)
intents_dict["lists"]["name"] = {
"values": [
{"in": name, "out": name, "context": context}
for name, context in entity_tuples
]
}
# Areas/floors
area_registry = ar.async_get(hass)
floor_registry = fr.async_get(hass)
for list_name, entries in (
("area", area_registry.async_list_areas()),
("floor", floor_registry.async_list_floors()),
):
entry_names = set()
for entry in entries:
entry_names.add(entry.name)
entry_names.update(filter(None, (a.strip() for a in entry.aliases)))
if entry_names:
intents_dict["lists"][list_name] = {"values": list(entry_names)}
# Parse and post-process
intents_dict["language"] = language
intents = Intents.from_dict(intents_dict)
if SENTENCE_TRIGGER_INTENT_NAME in intents.intents:
# Unknown lists in sentence triggers are wildcards
wildcard_names: set[str] = set()
for intent_data in intents.intents[SENTENCE_TRIGGER_INTENT_NAME].data:
for sentence in intent_data.sentences:
collect_list_references(sentence.expression, wildcard_names)
for wildcard_name in wildcard_names:
if wildcard_name in intents.slot_lists:
continue
intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
return intents
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Register the process service."""
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
hass.data[DATA_COMPONENT] = entity_component
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)
agent_config = config.get(DOMAIN, {})
await async_setup_default_agent(
hass, entity_component, config_intents=agent_config.get("intents", {})
)
async def handle_process(service: ServiceCall) -> ServiceResponse:
"""Parse text into commands."""
@@ -431,16 +294,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def handle_reload(service: ServiceCall) -> None:
"""Reload intents."""
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
agent = get_agent_manager(hass).default_agent
if agent is not None:
await agent.async_reload(language=language)
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
hass.services.async_register(
DOMAIN,
@@ -457,27 +313,6 @@ 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)
@@ -147,7 +147,6 @@ 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
@@ -200,16 +199,9 @@ 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)
@@ -30,27 +30,3 @@ class ConversationEntityFeature(IntFlag):
"""Supported features of the conversation entity."""
CONTROL = 1
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
METADATA_CUSTOM_FILE = "hass_custom_file"
SENTENCE_TRIGGER_INTENT_NAME = "HassSentenceTrigger"
CUSTOM_SENTENCES_DIR_NAME = "custom_sentences"
class IntentSource(IntFlag):
"""Source of intents and sentence templates."""
SENTENCE_TRIGGERS = 1
"""Sentence triggers in automations."""
CONVERSATION_CONFIG = 2
"""YAML configuration for conversation component."""
CUSTOM_SENTENCES = 4
"""YAML files in config/custom_sentences."""
BUILTIN_SENTENCES = 8
"""Sentences from home-assistant-intents package."""
ALL = SENTENCE_TRIGGERS | CONVERSATION_CONFIG | CUSTOM_SENTENCES | BUILTIN_SENTENCES
@@ -77,13 +77,7 @@ 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 (
CUSTOM_SENTENCES_DIR_NAME,
DOMAIN,
METADATA_CUSTOM_FILE,
METADATA_CUSTOM_SENTENCE,
ConversationEntityFeature,
)
from .const import DOMAIN, ConversationEntityFeature
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .trace import ConversationTraceEventType, async_conversation_trace_append
@@ -97,6 +91,8 @@ _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()
@@ -206,9 +202,10 @@ 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)
agent = DefaultAgent(hass, config_intents)
await entity_component.async_add_entities([agent])
await get_agent_manager(hass).async_setup_default_agent(agent)
@@ -233,14 +230,14 @@ class DefaultAgent(ConversationEntity):
_attr_name = "Home Assistant"
_attr_supported_features = ConversationEntityFeature.CONTROL
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
"""Initialize the default agent."""
self.hass = hass
self._lang_intents: dict[str, LanguageIntents | object] = {}
self._load_intents_lock = asyncio.Lock()
# Intents from common conversation config
self._config_intents: dict[str, Any] = {}
# intent -> [sentences]
self._config_intents: dict[str, Any] = config_intents
# Sentences that will trigger a callback (skipping intent recognition)
self._triggers_details: list[TriggerDetails] = []
@@ -266,16 +263,6 @@ class DefaultAgent(ConversationEntity):
"""Return a list of supported languages."""
return get_languages()
@property
def trigger_details(self) -> list[TriggerDetails]:
"""Get sentence trigger details."""
return self._triggers_details
@property
def config_intents(self) -> dict[str, Any]:
"""Get intents from conversation configuration."""
return self._config_intents
@callback
def _filter_entity_registry_changes(
self, event_data: er.EventEntityRegistryUpdatedData
@@ -1048,14 +1035,6 @@ 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:
@@ -1139,7 +1118,7 @@ class DefaultAgent(ConversationEntity):
# Check for custom sentences in <config>/custom_sentences/<language>/
custom_sentences_dir = Path(
self.hass.config.path(CUSTOM_SENTENCES_DIR_NAME, language_variant)
self.hass.config.path("custom_sentences", language_variant)
)
if custom_sentences_dir.is_dir():
for custom_sentences_path in custom_sentences_dir.rglob("*.yaml"):
@@ -1180,10 +1159,33 @@ class DefaultAgent(ConversationEntity):
custom_sentences_path,
)
merge_dict(
intents_dict,
self._config_intents,
)
# 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",
)
if not intents_dict:
return None
@@ -1478,7 +1480,7 @@ class DefaultAgent(ConversationEntity):
for trigger_intent in trigger_intents.intents.values():
for intent_data in trigger_intent.data:
for sentence in intent_data.sentences:
collect_list_references(sentence.expression, wildcard_names)
_collect_list_references(sentence.expression, wildcard_names)
for wildcard_name in wildcard_names:
trigger_intents.slot_lists[wildcard_name] = WildcardSlotList(wildcard_name)
@@ -1809,11 +1811,11 @@ def _get_match_error_response(
return ErrorKey.NO_INTENT, {}
def collect_list_references(expression: Expression, list_names: set[str]) -> None:
def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively."""
if isinstance(expression, Group):
for item in expression.items:
collect_list_references(item, list_names)
_collect_list_references(item, list_names)
elif isinstance(expression, ListReference):
# {list}
list_names.add(expression.slot_name)
+13 -21
View File
@@ -116,28 +116,20 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
is_open_wdr = None
is_open_hdr = None
reserve3 = product_info.get("reserve4")
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
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))
ret_sw, software_capabilities = self.session.getSWCapabilities()
supports_speak_volume_adjustment_val = (
bool(int(software_capabilities.get("swCapabilities1")) & 32)
if ret_sw == 0
@@ -0,0 +1,77 @@
"""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"]]
+2
View File
@@ -11,6 +11,7 @@ CONFIG_ENTRY_VERSION = 1
DEFAULT_NAME = "Hive"
DOMAIN = "hive"
PLATFORMS = [
Platform.ALARM_CONTROL_PANEL,
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
@@ -19,6 +20,7 @@ PLATFORMS = [
Platform.WATER_HEATER,
]
PLATFORM_LOOKUP = {
Platform.ALARM_CONTROL_PANEL: "alarm_control_panel",
Platform.BINARY_SENSOR: "binary_sensor",
Platform.CLIMATE: "climate",
Platform.LIGHT: "light",
+1 -1
View File
@@ -9,5 +9,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["apyhiveapi"],
"requirements": ["pyhive-integration==1.0.7"]
"requirements": ["pyhive-integration==1.0.6"]
}
@@ -1237,7 +1237,7 @@
"message": "Error obtaining data from the API: {error}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation temporarily unavailable, will retry"
},
"pause_program": {
"message": "Error pausing program: {error}"
@@ -4,7 +4,6 @@ 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
@@ -17,6 +16,7 @@ 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
@@ -8,7 +8,6 @@ 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
@@ -16,6 +15,7 @@ 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__)
@@ -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)
@@ -3,7 +3,6 @@
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
@@ -12,6 +11,7 @@ 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__)
@@ -18,6 +18,6 @@
},
"iot_class": "local_polling",
"loggers": ["aiopvapi"],
"requirements": ["aiopvapi==3.3.0"],
"requirements": ["aiopvapi==3.2.1"],
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
}
@@ -0,0 +1,80 @@
"""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
@@ -2,15 +2,25 @@
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
from homeassistant.core import HomeAssistant, callback
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:
+1 -3
View File
@@ -13,7 +13,6 @@ from typing import Any
from aiohttp import web
from hyperion import client
from hyperion.const import (
KEY_DATA,
KEY_IMAGE,
KEY_IMAGE_STREAM,
KEY_LEDCOLORS,
@@ -156,8 +155,7 @@ class HyperionCamera(Camera):
"""Update Hyperion components."""
if not img:
return
# Prefer KEY_DATA (Hyperion server >= 2.1.1); fall back to KEY_RESULT for older server versions
img_data = img.get(KEY_DATA, img.get(KEY_RESULT, {})).get(KEY_IMAGE)
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
return
async with self._image_cond:
@@ -5,6 +5,7 @@ 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 (
@@ -68,7 +69,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@property
@cached_property
def is_on(self) -> bool:
"""Return true if the binary sensor is on."""
return self.entity_description.value_fn(self.vehicle)
@@ -2,6 +2,8 @@
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
@@ -38,12 +40,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
"""Initialize the device tracker."""
super().__init__(coordinator, license_plate, "device_tracker")
@property
@cached_property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
return self.vehicle.gps_coordinates[0]
@property
@cached_property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
return self.vehicle.gps_coordinates[1]
+2 -1
View File
@@ -6,6 +6,7 @@ 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 (
@@ -132,7 +133,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
super().__init__(coordinator, license_plate, description.key)
self.entity_description = description
@property
@cached_property
def native_value(self) -> StateType | datetime:
"""Return the state of the device."""
return self.entity_description.value_fn(self.vehicle)
+22
View File
@@ -94,6 +94,28 @@
}
},
"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": {
@@ -353,13 +353,17 @@ DISCOVERY_SCHEMAS = [
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# DeviceFault or SupplyFault bit enabled
device_to_ha=lambda x: bool(
x
& (
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
)
),
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,
),
entity_class=MatterBinarySensor,
required_attributes=(
@@ -373,9 +377,9 @@ DISCOVERY_SCHEMAS = [
key="PumpStatusRunning",
translation_key="pump_running",
device_class=BinarySensorDeviceClass.RUNNING,
device_to_ha=lambda x: bool(
device_to_ha=lambda x: (
x
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
),
),
entity_class=MatterBinarySensor,
@@ -391,8 +395,8 @@ DISCOVERY_SCHEMAS = [
translation_key="dishwasher_alarm_inflow",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
),
),
entity_class=MatterBinarySensor,
@@ -406,8 +410,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: bool(
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
device_to_ha=lambda x: (
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
),
),
entity_class=MatterBinarySensor,
@@ -421,10 +425,9 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_general_fault",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# GeneralFault bit from ValveFault attribute
device_to_ha=lambda x: bool(
device_to_ha=lambda x: (
x
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
),
),
entity_class=MatterBinarySensor,
@@ -440,10 +443,9 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_blocked",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# Blocked bit from ValveFault attribute
device_to_ha=lambda x: bool(
device_to_ha=lambda x: (
x
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
),
),
entity_class=MatterBinarySensor,
@@ -459,10 +461,9 @@ DISCOVERY_SCHEMAS = [
translation_key="valve_fault_leaking",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
# Leaking bit from ValveFault attribute
device_to_ha=lambda x: bool(
device_to_ha=lambda x: (
x
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
),
),
entity_class=MatterBinarySensor,
@@ -477,8 +478,8 @@ DISCOVERY_SCHEMAS = [
translation_key="alarm_door",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
device_to_ha=lambda x: bool(
x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
device_to_ha=lambda x: (
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
),
),
entity_class=MatterBinarySensor,
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.1.0"]
"requirements": ["aiomealie==1.0.1"]
}
+2 -2
View File
@@ -1009,7 +1009,7 @@
"cleaning_care_program": "Cleaning/care program",
"maintenance_program": "Maintenance program",
"normal_operation_mode": "Normal operation mode",
"own_program": "Program"
"own_program": "Own program"
}
},
"remaining_time": {
@@ -1089,7 +1089,7 @@
"message": "Invalid device targeted."
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation unavailable, will retry"
},
"set_program_error": {
"message": "'Set program' action failed: {status} / {message}"
@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
from music_assistant_models.api import ServerInfoMessage
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
@@ -21,14 +21,21 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, LOGGER
DEFAULT_TITLE = "Music Assistant"
DEFAULT_URL = "http://mass.local:8095"
DEFAULT_TITLE = "Music Assistant"
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
"""Return a schema for the manual step."""
default_url = user_input.get(CONF_URL, DEFAULT_URL)
return vol.Schema(
{
vol.Required(CONF_URL, default=default_url): str,
}
)
async def _get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
async def get_server_info(hass: HomeAssistant, url: str) -> ServerInfoMessage:
"""Validate the user input allows us to connect."""
async with MusicAssistantClient(
url, aiohttp_client.async_get_clientsession(hass)
@@ -45,17 +52,25 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Set up flow instance."""
self.url: str | None = None
self.server_info: ServerInfoMessage | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a manual configuration."""
errors: dict[str, str] = {}
if user_input is not None:
try:
server_info = await _get_server_info(self.hass, user_input[CONF_URL])
self.server_info = await get_server_info(
self.hass, user_input[CONF_URL]
)
await self.async_set_unique_id(
self.server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_URL: user_input[CONF_URL]},
reload_on_update=True,
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidServerVersion:
@@ -64,49 +79,68 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
server_info.server_id, raise_on_progress=False
)
self._abort_if_unique_id_configured(
updates={CONF_URL: user_input[CONF_URL]}
)
return self.async_create_entry(
title=DEFAULT_TITLE,
data={CONF_URL: user_input[CONF_URL]},
data={
CONF_URL: user_input[CONF_URL],
},
)
suggested_values = user_input
if suggested_values is None:
suggested_values = {CONF_URL: DEFAULT_URL}
return self.async_show_form(
step_id="user", data_schema=get_manual_schema(user_input), errors=errors
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
STEP_USER_SCHEMA, suggested_values
),
errors=errors,
)
return self.async_show_form(step_id="user", data_schema=get_manual_schema({}))
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle a zeroconf discovery for a Music Assistant server."""
"""Handle a discovered Mass server.
This flow is triggered by the Zeroconf component. It will check if the
host is already configured and delegate to the import step if not.
"""
# abort if discovery info is not what we expect
if "server_id" not in discovery_info.properties:
return self.async_abort(reason="missing_server_id")
self.server_info = ServerInfoMessage.from_dict(discovery_info.properties)
await self.async_set_unique_id(self.server_info.server_id)
# Check if we already have a config entry for this server_id
existing_entry = self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, self.server_info.server_id
)
if existing_entry:
# If the entry was ignored or disabled, don't make any changes
if existing_entry.source == SOURCE_IGNORE or existing_entry.disabled_by:
return self.async_abort(reason="already_configured")
# Test connectivity to the current URL first
current_url = existing_entry.data[CONF_URL]
try:
await get_server_info(self.hass, current_url)
# Current URL is working, no need to update
return self.async_abort(reason="already_configured")
except CannotConnect:
# Current URL is not working, update to the discovered URL
# and continue to discovery confirm
self.hass.config_entries.async_update_entry(
existing_entry,
data={**existing_entry.data, CONF_URL: self.server_info.base_url},
)
# Schedule reload since URL changed
self.hass.config_entries.async_schedule_reload(existing_entry.entry_id)
else:
# No existing entry, proceed with normal flow
self._abort_if_unique_id_configured()
# Test connectivity to the discovered URL
try:
server_info = ServerInfoMessage.from_dict(discovery_info.properties)
except LookupError:
return self.async_abort(reason="invalid_discovery_info")
self.url = server_info.base_url
await self.async_set_unique_id(server_info.server_id)
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
try:
await _get_server_info(self.hass, self.url)
await get_server_info(self.hass, self.server_info.base_url)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
@@ -114,16 +148,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle user-confirmation of discovered server."""
if TYPE_CHECKING:
assert self.url is not None
assert self.server_info is not None
if user_input is not None:
return self.async_create_entry(
title=DEFAULT_TITLE,
data={CONF_URL: self.url},
data={
CONF_URL: self.server_info.base_url,
},
)
self._set_confirm_only()
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={"url": self.url},
description_placeholders={"url": self.server_info.base_url},
)
@@ -2,7 +2,7 @@
"domain": "music_assistant",
"name": "Music Assistant",
"after_dependencies": ["media_source", "media_player"],
"codeowners": ["@music-assistant", "@arturpragacz"],
"codeowners": ["@music-assistant"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"iot_class": "local_push",
@@ -57,7 +57,7 @@
"message": "Error while loading the integration."
},
"implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
"message": "OAuth2 implementation is not available, will retry."
},
"incorrect_oauth2_scope": {
"message": "Stored permissions are invalid. Please login again to update permissions."
@@ -12,12 +12,7 @@ from homeassistant.helpers import entity_registry as er
from .const import _LOGGER
PLATFORMS: list[Platform] = [
Platform.CLIMATE,
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
]
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE]
type NikoHomeControlConfigEntry = ConfigEntry[NHCController]
@@ -1,100 +0,0 @@
"""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
@@ -1,23 +1,6 @@
"""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
@@ -1,20 +0,0 @@
{
"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"
}
}
}
}
}
}
}
@@ -26,23 +26,5 @@
"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"
}
}
}
}
}
}
}
@@ -15,5 +15,5 @@
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["pypalazzetti==0.1.20"]
"requirements": ["pypalazzetti==0.1.19"]
}
@@ -256,7 +256,6 @@ 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(
@@ -54,7 +54,7 @@ class PlaystationNetworkSensor(StrEnum):
NOW_PLAYING = "now_playing"
SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.TROPHY_LEVEL,
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
@@ -106,6 +106,8 @@ SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
else None
),
),
)
SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
PlaystationNetworkSensorEntityDescription(
key=PlaystationNetworkSensor.ONLINE_ID,
translation_key=PlaystationNetworkSensor.ONLINE_ID,
@@ -150,7 +152,7 @@ async def async_setup_entry(
coordinator = config_entry.runtime_data.user_data
async_add_entities(
PlaystationNetworkSensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER
)
for (
@@ -164,7 +166,7 @@ async def async_setup_entry(
description,
config_entry.subentries[subentry_id],
)
for description in SENSOR_DESCRIPTIONS
for description in SENSOR_DESCRIPTIONS_USER
],
config_subentry_id=subentry_id,
)
+1 -10
View File
@@ -57,14 +57,12 @@ 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",
"zone_profiles",
"available_schedules",
]
# Default directives
@@ -84,10 +82,3 @@ 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"
@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.10.0"],
"requirements": ["plugwise==1.9.0"],
"zeroconf": ["_plugwise._tcp.local."]
}
+9 -23
View File
@@ -9,15 +9,7 @@ from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
SELECT_DHW_MODE,
SELECT_GATEWAY_MODE,
SELECT_REGULATION_MODE,
SELECT_SCHEDULE,
SELECT_ZONE_PROFILE,
SelectOptionsType,
SelectType,
)
from .const import SelectOptionsType, SelectType
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
from .entity import PlugwiseEntity
from .util import plugwise_command
@@ -35,34 +27,28 @@ 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=SELECT_REGULATION_MODE,
key="select_regulation_mode",
translation_key="regulation_mode",
entity_category=EntityCategory.CONFIG,
options_key="regulation_modes",
),
PlugwiseSelectEntityDescription(
key=SELECT_DHW_MODE,
translation_key=SELECT_DHW_MODE,
key="select_dhw_mode",
translation_key="dhw_mode",
entity_category=EntityCategory.CONFIG,
options_key="dhw_modes",
),
PlugwiseSelectEntityDescription(
key=SELECT_GATEWAY_MODE,
translation_key=SELECT_GATEWAY_MODE,
key="select_gateway_mode",
translation_key="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",
),
)
+3 -11
View File
@@ -109,7 +109,7 @@
}
},
"select": {
"select_dhw_mode": {
"dhw_mode": {
"name": "DHW mode",
"state": {
"auto": "[%key:common::state::auto%]",
@@ -118,7 +118,7 @@
"off": "[%key:common::state::off%]"
}
},
"select_gateway_mode": {
"gateway_mode": {
"name": "Gateway mode",
"state": {
"away": "Pause",
@@ -126,7 +126,7 @@
"vacation": "Vacation"
}
},
"select_regulation_mode": {
"regulation_mode": {
"name": "Regulation mode",
"state": {
"bleeding_cold": "Bleeding cold",
@@ -141,14 +141,6 @@
"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": {
@@ -26,9 +26,6 @@ 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)
+1 -1
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 = 52
UNIT_CLASS_SCHEMA_VERSION = 51
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 52
SCHEMA_VERSION = 51
_LOGGER = logging.getLogger(__name__)
+4 -73
View File
@@ -13,15 +13,7 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
from uuid import UUID
import sqlalchemy
from sqlalchemy import (
ForeignKeyConstraint,
MetaData,
Table,
cast as cast_,
func,
text,
update,
)
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
from sqlalchemy.engine import CursorResult, Engine
from sqlalchemy.exc import (
DatabaseError,
@@ -34,9 +26,8 @@ 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 and_, true
from sqlalchemy.sql.expression import 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
@@ -2053,74 +2044,14 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
def _apply_update(self) -> None:
"""Version specific update method."""
# 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 unit class column to StatisticsMeta
_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(
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),
)
)
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
.values(unit_class=conv.UNIT_CLASS)
)
@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
_LOGGER = logging.getLogger(__name__)
QUERY_STATISTICS_META = (
QUERY_STATISTIC_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_STATISTICS_META)
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
columns.append(StatisticsMeta.mean_type)
else:
@@ -2,15 +2,12 @@
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
@@ -20,7 +17,6 @@ from .const import (
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
@@ -44,9 +40,9 @@ async def async_setup_entry(
)
for subentry in zone_subentries:
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name: str = subentry.data[CONF_NAME]
zone_num = subentry.data[CONF_ZONE_NUMBER]
zone_type = subentry.data[CONF_ZONE_TYPE]
zone_name = subentry.data[CONF_NAME]
async_add_entities(
[
@@ -69,9 +65,9 @@ async def async_setup_entry(
)
for subentry in output_subentries:
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name: str = subentry.data[CONF_NAME]
output_num = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type = subentry.data[CONF_ZONE_TYPE]
output_name = subentry.data[CONF_NAME]
async_add_entities(
[
@@ -93,48 +89,68 @@ 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: AsyncSatel,
device_number: int,
device_name: str,
device_class: BinarySensorDeviceClass,
sensor_type: str,
react_to_signal: str,
config_entry_id: str,
) -> None:
controller,
device_number,
device_name,
zone_type,
sensor_type,
react_to_signal,
config_entry_id,
):
"""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:
self._attr_is_on = self._device_number in self._satel.violated_outputs
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
else:
self._attr_is_on = self._device_number in self._satel.violated_zones
self._state = 0
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: dict[int, int]):
def _devices_updated(self, zones):
"""Update the zone's state, if needed."""
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()
if self._device_number in zones and self._state != zones[self._device_number]:
self._state = zones[self._device_number]
self.async_write_ha_state()
+11 -14
View File
@@ -12,11 +12,10 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, httpx_client
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
from homeassistant.helpers import (
config_entry_oauth2_flow,
config_validation as cv,
httpx_client,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -29,21 +28,19 @@ _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE]
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up SENZ from a config 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
session = OAuth2Session(hass, entry, implementation)
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
hass, entry
)
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
senz_api = SENZAPI(auth)
+1 -1
View File
@@ -35,7 +35,7 @@ async def async_setup_entry(
)
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
class SENZClimate(CoordinatorEntity, ClimateEntity):
"""Representation of a SENZ climate entity."""
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@@ -1,29 +0,0 @@
"""Diagnostics platform for Senz integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
TO_REDACT = [
"access_token",
"refresh_token",
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
raw_data = (
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
)
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"thermostats": raw_data,
}
-93
View File
@@ -1,93 +0,0 @@
"""nVent RAYCHEM SENZ sensor platform."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from aiosenz import Thermostat
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from .const import DOMAIN
@dataclass(kw_only=True, frozen=True)
class SenzSensorDescription(SensorEntityDescription):
"""Describes SENZ sensor entity."""
value_fn: Callable[[Thermostat], str | int | float | None]
SENSORS: tuple[SenzSensorDescription, ...] = (
SenzSensorDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
value_fn=lambda data: data.current_temperatue,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ sensor entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities(
SENZSensor(thermostat, coordinator, description)
for description in SENSORS
for thermostat in coordinator.data.values()
)
class SENZSensor(CoordinatorEntity[SENZDataUpdateCoordinator], SensorEntity):
"""Representation of a SENZ sensor entity."""
entity_description: SenzSensorDescription
_attr_has_entity_name = True
def __init__(
self,
thermostat: Thermostat,
coordinator: SENZDataUpdateCoordinator,
description: SenzSensorDescription,
) -> None:
"""Init SENZ sensor."""
super().__init__(coordinator)
self.entity_description = description
self._thermostat = thermostat
self._attr_unique_id = f"{thermostat.serial_number}_{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, thermostat.serial_number)},
manufacturer="nVent Raychem",
model="SENZ WIFI",
name=thermostat.name,
serial_number=thermostat.serial_number,
)
@property
def available(self) -> bool:
"""Return True if the thermostat is available."""
return super().available and self._thermostat.online
@property
def native_value(self) -> str | float | int | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self._thermostat)
@@ -25,10 +25,5 @@
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
}
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}
+11 -23
View File
@@ -12,7 +12,6 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
from homeassistant.components.number import (
DOMAIN as NUMBER_PLATFORM,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberExtraStoredData,
@@ -108,9 +107,6 @@ 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."""
@@ -185,6 +181,7 @@ 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,
@@ -203,12 +200,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,
@@ -216,7 +213,7 @@ RPC_NUMBERS: Final = {
"number_generic": RpcNumberDescription(
key="number",
sub_key="value",
removal_condition=lambda config, _, key: not is_view_for_platform(
removal_condition=lambda config, _status, key: not is_view_for_platform(
config, key, NUMBER_PLATFORM
),
max_fn=lambda config: config["max"],
@@ -232,11 +229,9 @@ 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 _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -246,11 +241,10 @@ 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 _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -260,12 +254,10 @@ 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 _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -275,12 +267,10 @@ 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 _: NumberMode.SLIDER,
mode_fn=lambda config: NumberMode.SLIDER,
step_fn=lambda config: config["meta"]["ui"].get("step"),
unit=get_virtual_component_unit,
method="number_set",
@@ -291,20 +281,21 @@ 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, _, key: config[key].get("enable", True)
removal_condition=lambda config, _status, key: config[key].get("enable", True)
is True,
entity_class=RpcBluTrvNumber,
),
"left_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
translation_key="left_slot_intensity",
name="Left slot intensity",
value=lambda status, _: status["left"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -320,7 +311,7 @@ RPC_NUMBERS: Final = {
"right_slot_intensity": RpcNumberDescription(
key="cury",
sub_key="slots",
translation_key="right_slot_intensity",
name="Right slot intensity",
value=lambda status, _: status["right"]["intensity"],
native_min_value=0,
native_max_value=100,
@@ -411,9 +402,6 @@ 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()
@@ -188,29 +188,6 @@
}
}
},
"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",
@@ -30,5 +30,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",
"requirements": ["pysmartthings==3.3.2"]
"requirements": ["pysmartthings==3.3.1"]
}
@@ -75,7 +75,6 @@ PLATFORMS_BY_TYPE = {
SupportedModels.HUBMINI_MATTER.value: [Platform.SENSOR],
SupportedModels.CIRCULATOR_FAN.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.S10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.S20_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_PRO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
SupportedModels.K10_PRO_COMBO_VACUUM.value: [Platform.VACUUM, Platform.SENSOR],
@@ -103,10 +102,6 @@ PLATFORMS_BY_TYPE = {
SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR],
SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR],
SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR],
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: [
Platform.CLIMATE,
Platform.SENSOR,
],
}
CLASS_BY_DEVICE = {
SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight,
@@ -124,7 +119,6 @@ CLASS_BY_DEVICE = {
SupportedModels.ROLLER_SHADE.value: switchbot.SwitchbotRollerShade,
SupportedModels.CIRCULATOR_FAN.value: switchbot.SwitchbotFan,
SupportedModels.S10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.S20_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_PRO_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.K10_PRO_COMBO_VACUUM.value: switchbot.SwitchbotVacuum,
@@ -142,7 +136,6 @@ CLASS_BY_DEVICE = {
SupportedModels.PLUG_MINI_EU.value: switchbot.SwitchbotRelaySwitch,
SupportedModels.RELAY_SWITCH_2PM.value: switchbot.SwitchbotRelaySwitch2PM,
SupportedModels.GARAGE_DOOR_OPENER.value: switchbot.SwitchbotGarageDoorOpener,
SupportedModels.SMART_THERMOSTAT_RADIATOR.value: switchbot.SwitchbotSmartThermostatRadiator,
}
@@ -1,140 +0,0 @@
"""Support for Switchbot Climate devices."""
from __future__ import annotations
import logging
from typing import Any
import switchbot
from switchbot import (
ClimateAction as SwitchBotClimateAction,
ClimateMode as SwitchBotClimateMode,
)
from homeassistant.components.climate import (
ClimateEntity,
ClimateEntityFeature,
HVACAction,
HVACMode,
)
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SwitchbotConfigEntry
from .entity import SwitchbotEntity, exception_handler
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE = {
SwitchBotClimateMode.HEAT: HVACMode.HEAT,
SwitchBotClimateMode.OFF: HVACMode.OFF,
}
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE = {
HVACMode.HEAT: SwitchBotClimateMode.HEAT,
HVACMode.OFF: SwitchBotClimateMode.OFF,
}
SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION = {
SwitchBotClimateAction.HEATING: HVACAction.HEATING,
SwitchBotClimateAction.IDLE: HVACAction.IDLE,
SwitchBotClimateAction.OFF: HVACAction.OFF,
}
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: SwitchbotConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Switchbot climate based on a config entry."""
coordinator = entry.runtime_data
async_add_entities([SwitchBotClimateEntity(coordinator)])
class SwitchBotClimateEntity(SwitchbotEntity, ClimateEntity):
"""Representation of a Switchbot Climate device."""
_device: switchbot.SwitchbotDevice
_attr_supported_features = (
ClimateEntityFeature.PRESET_MODE
| ClimateEntityFeature.TARGET_TEMPERATURE
| ClimateEntityFeature.TURN_OFF
| ClimateEntityFeature.TURN_ON
)
_attr_target_temperature_step = 0.5
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_translation_key = "climate"
_attr_name = None
@property
def min_temp(self) -> float:
"""Return the minimum temperature."""
return self._device.min_temperature
@property
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self._device.max_temperature
@property
def preset_modes(self) -> list[str] | None:
"""Return the list of available preset modes."""
return self._device.preset_modes
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
return self._device.preset_mode
@property
def hvac_mode(self) -> HVACMode | None:
"""Return the current HVAC mode."""
return SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE.get(
self._device.hvac_mode, HVACMode.OFF
)
@property
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available HVAC modes."""
return [
SWITCHBOT_CLIMATE_TO_HASS_HVAC_MODE[mode]
for mode in self._device.hvac_modes
]
@property
def hvac_action(self) -> HVACAction | None:
"""Return the current HVAC action."""
return SWITCHBOT_ACTION_TO_HASS_HVAC_ACTION.get(
self._device.hvac_action, HVACAction.OFF
)
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._device.current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._device.target_temperature
@exception_handler
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new HVAC mode."""
return await self._device.set_hvac_mode(
HASS_HVAC_MODE_TO_SWITCHBOT_CLIMATE[hvac_mode]
)
@exception_handler
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
return await self._device.set_preset_mode(preset_mode)
@exception_handler
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
temperature = kwargs.get(ATTR_TEMPERATURE)
return await self._device.set_target_temperature(temperature)
@@ -58,8 +58,6 @@ class SupportedModels(StrEnum):
K11_PLUS_VACUUM = "k11+_vacuum"
GARAGE_DOOR_OPENER = "garage_door_opener"
CLIMATE_PANEL = "climate_panel"
SMART_THERMOSTAT_RADIATOR = "smart_thermostat_radiator"
S20_VACUUM = "s20_vacuum"
CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -80,7 +78,6 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.CIRCULATOR_FAN: SupportedModels.CIRCULATOR_FAN,
SwitchbotModel.K20_VACUUM: SupportedModels.K20_VACUUM,
SwitchbotModel.S10_VACUUM: SupportedModels.S10_VACUUM,
SwitchbotModel.S20_VACUUM: SupportedModels.S20_VACUUM,
SwitchbotModel.K10_VACUUM: SupportedModels.K10_VACUUM,
SwitchbotModel.K10_PRO_VACUUM: SupportedModels.K10_PRO_VACUUM,
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
@@ -98,7 +95,6 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM,
SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER,
SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: SupportedModels.SMART_THERMOSTAT_RADIATOR,
}
NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = {
@@ -136,7 +132,6 @@ ENCRYPTED_MODELS = {
SwitchbotModel.PLUG_MINI_EU,
SwitchbotModel.RELAY_SWITCH_2PM,
SwitchbotModel.GARAGE_DOOR_OPENER,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR,
}
ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
@@ -158,7 +153,6 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.PLUG_MINI_EU: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.RELAY_SWITCH_2PM: switchbot.SwitchbotRelaySwitch2PM,
SwitchbotModel.GARAGE_DOOR_OPENER: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.SMART_THERMOSTAT_RADIATOR: switchbot.SwitchbotSmartThermostatRadiator,
}
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
@@ -1,18 +1,5 @@
{
"entity": {
"climate": {
"climate": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "mdi:hand-back-right",
"off": "mdi:hvac-off",
"schedule": "mdi:calendar-clock"
}
}
}
}
},
"fan": {
"air_purifier": {
"default": "mdi:air-purifier",
@@ -41,5 +41,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==0.73.0"]
"requirements": ["PySwitchbot==0.72.1"]
}
@@ -100,19 +100,6 @@
"name": "Unlocked alarm"
}
},
"climate": {
"climate": {
"state_attributes": {
"preset_mode": {
"state": {
"manual": "[%key:common::state::manual%]",
"off": "[%key:common::state::off%]",
"schedule": "Schedule"
}
}
}
}
},
"cover": {
"cover": {
"state_attributes": {
@@ -84,7 +84,6 @@
"abort": {
"already_configured": "Chat already configured"
},
"entry_type": "Allowed chat ID",
"error": {
"chat_not_found": "Chat not found"
},
+9 -8
View File
@@ -181,14 +181,15 @@ class TPLinkClimateEntity(CoordinatedTPLinkModuleEntity, ClimateEntity):
HVACMode.HEAT if self._thermostat_module.state else HVACMode.OFF
)
if self._thermostat_module.mode not in STATE_TO_ACTION:
# Report a warning on the first non-default unknown mode
if self._attr_hvac_action is not HVACAction.OFF:
_LOGGER.warning(
"Unknown thermostat state, defaulting to OFF: %s",
self._thermostat_module.mode,
)
self._attr_hvac_action = HVACAction.OFF
if (
self._thermostat_module.mode not in STATE_TO_ACTION
and self._attr_hvac_action is not HVACAction.OFF
):
_LOGGER.warning(
"Unknown thermostat state, defaulting to OFF: %s",
self._thermostat_module.mode,
)
self._attr_hvac_action = HVACAction.OFF
return True
self._attr_hvac_action = STATE_TO_ACTION[self._thermostat_module.mode]
@@ -2,14 +2,13 @@
from functools import partial
import logging
from typing import cast
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import CONF_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, selector
from .const import (
@@ -24,7 +23,7 @@ from .const import (
SERVICE_START_TORRENT,
SERVICE_STOP_TORRENT,
)
from .coordinator import TransmissionDataUpdateCoordinator
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -68,52 +67,45 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All(
def _get_coordinator_from_service_data(
call: ServiceCall,
hass: HomeAssistant, entry_id: str
) -> TransmissionDataUpdateCoordinator:
"""Return coordinator for entry id."""
config_entry_id: str = call.data[CONF_ENTRY_ID]
if not (entry := call.hass.config_entries.async_get_entry(config_entry_id)):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="integration_not_found",
translation_placeholders={"target": DOMAIN},
)
if entry.state is not ConfigEntryState.LOADED:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="not_loaded",
translation_placeholders={"target": entry.title},
)
return cast(TransmissionDataUpdateCoordinator, entry.runtime_data)
entry: TransmissionConfigEntry | None = hass.config_entries.async_get_entry(
entry_id
)
if entry is None or entry.state is not ConfigEntryState.LOADED:
raise HomeAssistantError(f"Config entry {entry_id} is not found or not loaded")
return entry.runtime_data
async def _async_add_torrent(service: ServiceCall) -> None:
"""Add new torrent to download."""
coordinator = _get_coordinator_from_service_data(service)
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
torrent: str = service.data[ATTR_TORRENT]
download_path: str | None = service.data.get(ATTR_DOWNLOAD_PATH)
if not (
torrent.startswith(("http", "ftp:", "magnet:"))
or service.hass.config.is_allowed_path(torrent)
):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="could_not_add_torrent",
)
if download_path:
await service.hass.async_add_executor_job(
partial(coordinator.api.add_torrent, torrent, download_dir=download_path)
)
if torrent.startswith(
("http", "ftp:", "magnet:")
) or service.hass.config.is_allowed_path(torrent):
if download_path:
await service.hass.async_add_executor_job(
partial(
coordinator.api.add_torrent, torrent, download_dir=download_path
)
)
else:
await service.hass.async_add_executor_job(
coordinator.api.add_torrent, torrent
)
await coordinator.async_request_refresh()
else:
await service.hass.async_add_executor_job(coordinator.api.add_torrent, torrent)
await coordinator.async_request_refresh()
_LOGGER.warning("Could not add torrent: unsupported type or no permission")
async def _async_start_torrent(service: ServiceCall) -> None:
"""Start torrent."""
coordinator = _get_coordinator_from_service_data(service)
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
torrent_id = service.data[CONF_ID]
await service.hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id)
await coordinator.async_request_refresh()
@@ -121,7 +113,8 @@ async def _async_start_torrent(service: ServiceCall) -> None:
async def _async_stop_torrent(service: ServiceCall) -> None:
"""Stop torrent."""
coordinator = _get_coordinator_from_service_data(service)
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
torrent_id = service.data[CONF_ID]
await service.hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id)
await coordinator.async_request_refresh()
@@ -129,7 +122,8 @@ async def _async_stop_torrent(service: ServiceCall) -> None:
async def _async_remove_torrent(service: ServiceCall) -> None:
"""Remove torrent."""
coordinator = _get_coordinator_from_service_data(service)
entry_id: str = service.data[CONF_ENTRY_ID]
coordinator = _get_coordinator_from_service_data(service.hass, entry_id)
torrent_id = service.data[CONF_ID]
delete_data = service.data[ATTR_DELETE_DATA]
await service.hass.async_add_executor_job(
@@ -1,7 +1,6 @@
add_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
@@ -19,7 +18,6 @@ add_torrent:
remove_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
@@ -29,7 +27,6 @@ remove_torrent:
selector:
text:
delete_data:
required: true
default: false
selector:
boolean:
@@ -37,12 +34,10 @@ remove_torrent:
start_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
id:
required: true
example: 123
selector:
text:
@@ -50,7 +45,6 @@ start_torrent:
stop_torrent:
fields:
entry_id:
required: true
selector:
config_entry:
integration: transmission
@@ -87,17 +87,6 @@
}
}
},
"exceptions": {
"could_not_add_torrent": {
"message": "Could not add torrent: unsupported type or no permission."
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."
},
"not_loaded": {
"message": "{target} is not loaded."
}
},
"options": {
"step": {
"init": {
@@ -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
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import DPCodeEnumWrapper
from .models import EnumTypeData, find_dpcode
from .util import get_dpcode
@@ -85,21 +85,9 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := ALARM.get(device.category):
entities.extend(
TuyaAlarmEntity(
device,
manager,
description,
action_dpcode_wrapper=action_dpcode_wrapper,
state_dpcode_wrapper=DPCodeEnumWrapper.find_dpcode(
device, description.master_state
),
)
TuyaAlarmEntity(device, manager, description)
for description in descriptions
if (
action_dpcode_wrapper := DPCodeEnumWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
if description.key in device.status
)
async_add_entities(entities)
@@ -115,6 +103,7 @@ 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__(
@@ -122,24 +111,33 @@ 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 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
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
# Determine alarm message
if dp_code := get_dpcode(self.device, description.alarm_msg):
@@ -151,8 +149,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._state_dpcode_wrapper is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
self._master_state is not None
and self.device.status.get(self._master_state.dpcode) == State.ALARM
):
# Only report as triggered if NOT a battery warning
if (
@@ -168,26 +166,28 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
def changed_by(self) -> str | None:
"""Last change triggered by."""
if (
self._state_dpcode_wrapper is not None
self._master_state is not None
and self._alarm_msg_dpcode is not None
and self.device.status.get(self._state_dpcode_wrapper.dpcode) == State.ALARM
and self.device.status.get(self._master_state.dpcode) == State.ALARM
and (encoded_msg := self.device.status.get(self._alarm_msg_dpcode))
):
return b64decode(encoded_msg).decode("utf-16be")
return None
async def async_alarm_disarm(self, code: str | None = None) -> None:
def alarm_disarm(self, code: str | None = None) -> None:
"""Send Disarm command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.DISARMED)
self._send_command(
[{"code": self.entity_description.key, "value": Mode.DISARMED}]
)
async def async_alarm_arm_home(self, code: str | None = None) -> None:
def alarm_arm_home(self, code: str | None = None) -> None:
"""Send Home command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.HOME)
self._send_command([{"code": self.entity_description.key, "value": Mode.HOME}])
async def async_alarm_arm_away(self, code: str | None = None) -> None:
def alarm_arm_away(self, code: str | None = None) -> None:
"""Send Arm command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.ARM)
self._send_command([{"code": self.entity_description.key, "value": Mode.ARM}])
async def async_alarm_trigger(self, code: str | None = None) -> None:
def alarm_trigger(self, code: str | None = None) -> None:
"""Send SOS command."""
await self._async_send_dpcode_update(self._action_dpcode_wrapper, Mode.SOS)
self._send_command([{"code": self.entity_description.key, "value": Mode.SOS}])
+49 -52
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
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType
from .entity import TuyaEntity
from .models import DPCodeBitmapBitWrapper, DPCodeBooleanWrapper, DPCodeWrapper
@dataclass(frozen=True)
@@ -366,48 +366,20 @@ BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ..
}
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:
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
):
return None
return _CustomDPCodeWrapper(
dpcode,
description.on_value
if isinstance(description.on_value, set)
else {description.on_value},
)
return bitmap_labels.index(bitmap_key)
async def async_setup_entry(
@@ -425,11 +397,25 @@ async def async_setup_entry(
for device_id in device_ids:
device = manager.device_map[device_id]
if descriptions := BINARY_SENSORS.get(device.category):
entities.extend(
TuyaBinarySensorEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if (dpcode_wrapper := _get_dpcode_wrapper(device, description))
)
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,
)
)
async_add_entities(entities)
@@ -450,15 +436,26 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
device: CustomerDevice,
device_manager: Manager,
description: TuyaBinarySensorEntityDescription,
dpcode_wrapper: DPCodeWrapper,
bit_mask: int | None = None,
) -> 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._dpcode_wrapper = dpcode_wrapper
self._bit_mask = bit_mask
@property
def is_on(self) -> bool | None:
def is_on(self) -> bool:
"""Return true if sensor is on."""
return self._dpcode_wrapper.read_device_status(self.device)
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
+4 -24
View File
@@ -13,7 +13,6 @@ 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: (
@@ -22,19 +21,6 @@ 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,
@@ -81,13 +67,9 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := BUTTONS.get(device.category):
entities.extend(
TuyaButtonEntity(device, manager, description, dpcode_wrapper)
TuyaButtonEntity(device, manager, description)
for description in descriptions
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
if description.key in device.status
)
async_add_entities(entities)
@@ -107,14 +89,12 @@ 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
async def async_press(self) -> None:
def press(self) -> None:
"""Press the button."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
self._send_command([{"code": self.entity_description.key, "value": True}])
-3
View File
@@ -704,7 +704,6 @@ 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"
@@ -718,7 +717,6 @@ 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
@@ -775,7 +773,6 @@ 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
-7
View File
@@ -240,13 +240,6 @@ 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,
+29 -89
View File
@@ -22,18 +22,17 @@ 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."""
return cls(dpcode)
raise NotImplementedError("from_json is not implemented for this type")
@dataclass
class IntegerTypeData(TypeInformation):
"""Integer Type Data."""
dpcode: DPCode
min: int
max: int
scale: float
@@ -101,24 +100,11 @@ 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
@@ -130,8 +116,6 @@ class EnumTypeData(TypeInformation):
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {
DPType.BITMAP: BitmapTypeInformation,
DPType.BOOLEAN: TypeInformation,
DPType.ENUM: EnumTypeData,
DPType.INTEGER: IntegerTypeData,
}
@@ -162,13 +146,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 if needed.
back to the device, and should be implemented in concrete classes.
"""
raise NotImplementedError
def get_update_command(self, device: CustomerDevice, value: Any) -> dict[str, Any]:
"""Get the update command for the dpcode.
@@ -181,6 +165,29 @@ 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."""
@@ -196,7 +203,7 @@ class DPCodeTypeInformationWrapper[T: TypeInformation](DPCodeWrapper):
def find_dpcode(
cls,
device: CustomerDevice,
dpcodes: str | DPCode | tuple[DPCode, ...] | None,
dpcodes: str | DPCode | tuple[DPCode, ...],
*,
prefer_function: bool = False,
) -> Self | None:
@@ -210,31 +217,6 @@ 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."""
@@ -290,48 +272,6 @@ 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,
-8
View File
@@ -180,14 +180,6 @@ 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,
+8 -15
View File
@@ -19,7 +19,6 @@ 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: (
@@ -65,13 +64,9 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SIRENS.get(device.category):
entities.extend(
TuyaSirenEntity(device, manager, description, dpcode_wrapper)
TuyaSirenEntity(device, manager, description)
for description in descriptions
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
)
if description.key in device.status
)
async_add_entities(entities)
@@ -94,23 +89,21 @@ 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 | None:
def is_on(self) -> bool:
"""Return true if siren is on."""
return self._dpcode_wrapper.read_device_status(self.device)
return self.device.status.get(self.entity_description.key, False)
async def async_turn_on(self, **kwargs: Any) -> None:
def turn_on(self, **kwargs: Any) -> None:
"""Turn the siren on."""
await self._async_send_dpcode_update(self._dpcode_wrapper, True)
self._send_command([{"code": self.entity_description.key, "value": True}])
async def async_turn_off(self, **kwargs: Any) -> None:
def turn_off(self, **kwargs: Any) -> None:
"""Turn the siren off."""
await self._async_send_dpcode_update(self._dpcode_wrapper, False)
self._send_command([{"code": self.entity_description.key, "value": False}])
@@ -77,12 +77,6 @@
}
},
"button": {
"factory_reset": {
"name": "Factory reset"
},
"manual_clean": {
"name": "Manual clean"
},
"reset_duster_cloth": {
"name": "Reset duster cloth"
},
@@ -172,9 +166,6 @@
"cook_time": {
"name": "Cooking time"
},
"delay_clean_time": {
"name": "Delay clean time"
},
"down_delay": {
"name": "Down delay"
},
+7 -6
View File
@@ -946,13 +946,14 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := SWITCHES.get(device.category):
entities.extend(
TuyaSwitchEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
TuyaSwitchEntity(
device,
manager,
description,
DPCodeBooleanWrapper(description.key),
)
for description in descriptions
if description.key in device.status
and _check_deprecation(
hass,
device,
+7 -6
View File
@@ -94,13 +94,14 @@ async def async_setup_entry(
device = manager.device_map[device_id]
if descriptions := VALVES.get(device.category):
entities.extend(
TuyaValveEntity(device, manager, description, dpcode_wrapper)
for description in descriptions
if (
dpcode_wrapper := DPCodeBooleanWrapper.find_dpcode(
device, description.key, prefer_function=True
)
TuyaValveEntity(
device,
manager,
description,
DPCodeBooleanWrapper(description.key),
)
for description in descriptions
if description.key in device.status
)
async_add_entities(entities)
@@ -2,23 +2,13 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import aiohttp
from uasiren.client import Client
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_REGION
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, PLATFORMS
from .coordinator import UkraineAlarmDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ukraine Alarm as config entry."""
@@ -40,56 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Migrate old entry."""
_LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
# Version 1 had states as first-class selections
# Version 2 only allows states w/o districts, districts and communities
region_id = config_entry.data[CONF_REGION]
websession = async_get_clientsession(hass)
try:
regions_data = await Client(websession).get_regions()
except (aiohttp.ClientError, TimeoutError) as err:
_LOGGER.warning(
"Could not migrate config entry %s: failed to fetch current regions: %s",
config_entry.entry_id,
err,
)
return False
if TYPE_CHECKING:
assert isinstance(regions_data, dict)
state_with_districts = None
for state in regions_data["states"]:
if state["regionId"] == region_id and state.get("regionChildIds"):
state_with_districts = state
break
if state_with_districts:
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_state_region_{config_entry.entry_id}",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_state_region",
translation_placeholders={
"region_name": config_entry.data.get(CONF_NAME, region_id),
},
)
return False
hass.config_entries.async_update_entry(config_entry, version=2)
_LOGGER.info("Migration to version %s successful", 2)
return True
_LOGGER.error("Unknown version %s", config_entry.version)
return False
@@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
"""Config flow for Ukraine Alarm."""
VERSION = 2
VERSION = 1
def __init__(self) -> None:
"""Initialize a new UkraineAlarmConfigFlow."""
@@ -112,7 +112,7 @@ class UkraineAlarmConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_finish_flow()
regions = {}
if self.selected_region and step_id != "district":
if self.selected_region:
regions[self.selected_region["regionId"]] = self.selected_region[
"regionName"
]
@@ -13,19 +13,19 @@
"data": {
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
},
"description": "Choose the district you selected above or select a specific community within that district"
"description": "If you want to monitor not only state and district, choose its specific community"
},
"district": {
"data": {
"region": "[%key:component::ukraine_alarm::config::step::user::data::region%]"
},
"description": "Choose a district to monitor within the selected state"
"description": "If you want to monitor not only state, choose its specific district"
},
"user": {
"data": {
"region": "Region"
},
"description": "Choose a state"
"description": "Choose state to monitor"
}
}
},
@@ -50,11 +50,5 @@
"name": "Urban fights"
}
}
},
"issues": {
"deprecated_state_region": {
"description": "The region `{region_name}` is a state-level region, which is no longer supported. Please remove this integration entry and add it again, selecting a district or community instead of the entire state.",
"title": "State-level region monitoring is no longer supported"
}
}
}
@@ -14,7 +14,7 @@
"velbus-protocol"
],
"quality_scale": "bronze",
"requirements": ["velbus-aio==2025.11.0"],
"requirements": ["velbus-aio==2025.8.0"],
"usb": [
{
"pid": "0B1B",
+2 -20
View File
@@ -1,20 +1,17 @@
"""Support for VELUX KLF 200 devices."""
from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr
from .const import DOMAIN, LOGGER, PLATFORMS
type VeluxConfigEntry = ConfigEntry[PyVLX]
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up the velux component."""
host = entry.data[CONF_HOST]
password = entry.data[CONF_PASSWORD]
@@ -30,21 +27,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
entry.runtime_data = pyvlx
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
identifiers={(DOMAIN, f"gateway_{entry.entry_id}")},
name="KLF 200 Gateway",
manufacturer="Velux",
model="KLF 200",
hw_version=(
str(pyvlx.klf200.version.hardwareversion) if pyvlx.klf200.version else None
),
sw_version=(
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
),
)
async def on_hass_stop(event):
"""Close connection when hass stops."""
LOGGER.debug("Velux interface terminated")
@@ -64,6 +46,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
return True
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -24,14 +24,14 @@ SCAN_INTERVAL = timedelta(minutes=5) # Use standard polling
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
config: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up rain sensor(s) for Velux platform."""
pyvlx = config_entry.runtime_data
pyvlx = config.runtime_data
async_add_entities(
VeluxRainSensor(node, config_entry.entry_id)
VeluxRainSensor(node, config.entry_id)
for node in pyvlx.nodes
if isinstance(node, Window) and node.rain_sensor
)
-52
View File
@@ -1,52 +0,0 @@
"""Support for VELUX KLF 200 gateway button."""
from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up button entities for the Velux integration."""
async_add_entities(
[VeluxGatewayRebootButton(config_entry.entry_id, config_entry.runtime_data)]
)
class VeluxGatewayRebootButton(ButtonEntity):
"""Representation of the Velux Gateway reboot button."""
_attr_has_entity_name = True
_attr_device_class = ButtonDeviceClass.RESTART
_attr_entity_category = EntityCategory.CONFIG
def __init__(self, config_entry_id: str, pyvlx: PyVLX) -> None:
"""Initialize the gateway reboot button."""
self.pyvlx = pyvlx
self._attr_unique_id = f"{config_entry_id}_reboot-gateway"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, f"gateway_{config_entry_id}")},
)
async def async_press(self) -> None:
"""Handle the button press - reboot the gateway."""
try:
await self.pyvlx.reboot_gateway()
except PyVLXException as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="reboot_failed",
) from ex
+1 -7
View File
@@ -5,11 +5,5 @@ from logging import getLogger
from homeassistant.const import Platform
DOMAIN = "velux"
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.COVER,
Platform.LIGHT,
Platform.SCENE,
]
PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.LIGHT, Platform.SCENE]
LOGGER = getLogger(__package__)
+3 -3
View File
@@ -32,13 +32,13 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
config: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up cover(s) for Velux platform."""
pyvlx = config_entry.runtime_data
pyvlx = config.runtime_data
async_add_entities(
VeluxCover(node, config_entry.entry_id)
VeluxCover(node, config.entry_id)
for node in pyvlx.nodes
if isinstance(node, OpeningDevice)
)
+4 -5
View File
@@ -18,23 +18,22 @@ class VeluxEntity(Entity):
def __init__(self, node: Node, config_entry_id: str) -> None:
"""Initialize the Velux device."""
self.node = node
unique_id = (
self._attr_unique_id = (
node.serial_number
if node.serial_number
else f"{config_entry_id}_{node.node_id}"
)
self._attr_unique_id = unique_id
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
unique_id,
node.serial_number
if node.serial_number
else f"{config_entry_id}_{node.node_id}",
)
},
name=node.name if node.name else f"#{node.node_id}",
serial_number=node.serial_number,
via_device=(DOMAIN, f"gateway_{config_entry_id}"),
)
@callback
+3 -4
View File
@@ -18,13 +18,13 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
config: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up light(s) for Velux platform."""
pyvlx = config_entry.runtime_data
pyvlx = config.runtime_data
async_add_entities(
VeluxLight(node, config_entry.entry_id)
VeluxLight(node, config.entry_id)
for node in pyvlx.nodes
if isinstance(node, LighteningDevice)
)
@@ -35,7 +35,6 @@ class VeluxLight(VeluxEntity, LightEntity):
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_color_mode = ColorMode.BRIGHTNESS
_attr_name = None
node: LighteningDevice
+2 -2
View File
@@ -15,11 +15,11 @@ PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,
config_entry: VeluxConfigEntry,
config: VeluxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the scenes for Velux platform."""
pyvlx = config_entry.runtime_data
pyvlx = config.runtime_data
entities = [VeluxScene(scene) for scene in pyvlx.scenes]
async_add_entities(entities)
+1 -6
View File
@@ -36,14 +36,9 @@
}
}
},
"exceptions": {
"reboot_failed": {
"message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually"
}
},
"services": {
"reboot_gateway": {
"description": "Reboots the KLF200 Gateway",
"description": "Reboots the KLF200 Gateway.",
"name": "Reboot gateway"
}
}
@@ -11,7 +11,6 @@ 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
@@ -122,21 +121,3 @@ 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
-10
View File
@@ -22,7 +22,6 @@ from homeassistant.const import (
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -140,15 +139,6 @@ 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,
),
)
+1 -2
View File
@@ -58,7 +58,6 @@ from .utils import (
get_compressors,
get_device_serial,
is_supported,
normalize_state,
)
_LOGGER = logging.getLogger(__name__)
@@ -1087,7 +1086,7 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="compressor_phase",
translation_key="compressor_phase",
value_getter=lambda api: normalize_state(api.getPhase()),
value_getter=lambda api: api.getPhase(),
entity_category=EntityCategory.DIAGNOSTIC,
),
)

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