mirror of
https://github.com/home-assistant/core.git
synced 2025-11-14 13:30:43 +00:00
Compare commits
184 Commits
copilot/ad
...
mqtt-entit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e4676b4c | ||
|
|
f852220282 | ||
|
|
5dd3bf04eb | ||
|
|
b0c2fdc57b | ||
|
|
617d44ffcf | ||
|
|
8fb8eed1c8 | ||
|
|
1ddbd4755b | ||
|
|
3bd76294dc | ||
|
|
bb97822db9 | ||
|
|
33ffccabd1 | ||
|
|
56de03ce33 | ||
|
|
0cbf7002a8 | ||
|
|
cffceffe04 | ||
|
|
253189805e | ||
|
|
2e91725ac0 | ||
|
|
3b54dddc08 | ||
|
|
9bc3d83a55 | ||
|
|
d62a554cbf | ||
|
|
f071b7cd46 | ||
|
|
37f34f6189 | ||
|
|
27dc5b6d18 | ||
|
|
0bbc2f49a6 | ||
|
|
c121fa25e8 | ||
|
|
660cea8b65 | ||
|
|
c7749ebae1 | ||
|
|
a2acb744b3 | ||
|
|
0d9158689d | ||
|
|
f85e8d6c1f | ||
|
|
9be4cc5af1 | ||
|
|
a141eedf2c | ||
|
|
03040c131c | ||
|
|
3eef50632c | ||
|
|
eff150cd54 | ||
|
|
6dcc94b0a1 | ||
|
|
7201903877 | ||
|
|
5b776307ea | ||
|
|
3cb414511b | ||
|
|
f55c36d42d | ||
|
|
26bb301cc0 | ||
|
|
4159e483ee | ||
|
|
7eb6f7cc07 | ||
|
|
a7d01b0b03 | ||
|
|
1e5cfddf83 | ||
|
|
006fc5b10a | ||
|
|
35a4b685b3 | ||
|
|
b166818ef4 | ||
|
|
34cd9f11d0 | ||
|
|
0711d62085 | ||
|
|
f70aeafb5f | ||
|
|
e2279b3589 | ||
|
|
87b68e99ec | ||
|
|
b6c8b787e8 | ||
|
|
78f26edc29 | ||
|
|
5e6a72de90 | ||
|
|
dcc559f8b6 | ||
|
|
eda49cced0 | ||
|
|
14e41ab119 | ||
|
|
46151456d8 | ||
|
|
39773a022a | ||
|
|
5f49a6450f | ||
|
|
dc8425c580 | ||
|
|
910bd371e4 | ||
|
|
802a225e11 | ||
|
|
84f66fa689 | ||
|
|
0b7e88d0e0 | ||
|
|
1fcaf95df5 | ||
|
|
6c7434531f | ||
|
|
5ec1c2b68b | ||
|
|
d8636d8346 | ||
|
|
434763c74d | ||
|
|
8cd2c1b43b | ||
|
|
44711787a4 | ||
|
|
98fd0ee683 | ||
|
|
303e4ce961 | ||
|
|
76f29298cd | ||
|
|
17f5d0a69f | ||
|
|
90561de438 | ||
|
|
aedd48c298 | ||
|
|
febbb85532 | ||
|
|
af67a35b75 | ||
|
|
dd34d458f5 | ||
|
|
603d4bcf87 | ||
|
|
2dadc1f2b3 | ||
|
|
936151fae5 | ||
|
|
9760eb7f2b | ||
|
|
7851bed00c | ||
|
|
6aba0b20c6 | ||
|
|
cadfed2348 | ||
|
|
44e2fa6996 | ||
|
|
d0ff617e17 | ||
|
|
8e499569a4 | ||
|
|
5e0ebddd6f | ||
|
|
c0f61f6c2b | ||
|
|
df60de38b0 | ||
|
|
cb086bb8e9 | ||
|
|
ee2e9dc7d6 | ||
|
|
85cd3c68b7 | ||
|
|
1b0b6e63f2 | ||
|
|
12fc79e8d3 | ||
|
|
ca2e7b9509 | ||
|
|
8e8becc43e | ||
|
|
dcec6c3dc8 | ||
|
|
c0e59c4508 | ||
|
|
cd379aadbf | ||
|
|
ccdd54b187 | ||
|
|
3f22dbaa2e | ||
|
|
c18dc0a9ab | ||
|
|
f0e4296d93 | ||
|
|
b3750109c6 | ||
|
|
93025c9845 | ||
|
|
df348644b1 | ||
|
|
8749b0d750 | ||
|
|
a6a1519c06 | ||
|
|
3068e19843 | ||
|
|
55feb1e735 | ||
|
|
bb7dc69131 | ||
|
|
aa9003a524 | ||
|
|
4e9da5249d | ||
|
|
f502739df2 | ||
|
|
0f2ff29378 | ||
|
|
2921e7ed3c | ||
|
|
25d44e8d37 | ||
|
|
0a480a26a3 | ||
|
|
d5da64dd8d | ||
|
|
92adcd8635 | ||
|
|
ee0c4b15c2 | ||
|
|
507f54198e | ||
|
|
0ed342b433 | ||
|
|
363c86faf3 | ||
|
|
095a7ad060 | ||
|
|
ab5981bbbd | ||
|
|
ac2fb53dfd | ||
|
|
02ff5de1ff | ||
|
|
5cd5d480d9 | ||
|
|
a3c7d772fc | ||
|
|
fe0c69dba7 | ||
|
|
e5365234c3 | ||
|
|
1531175bd3 | ||
|
|
62add59ff4 | ||
|
|
d8daca657b | ||
|
|
1891da46ea | ||
|
|
22ae894745 | ||
|
|
160810c69d | ||
|
|
2ae23b920a | ||
|
|
a7edfb082f | ||
|
|
3ac203b05f | ||
|
|
7c3eb19fc4 | ||
|
|
70c6fac743 | ||
|
|
e19d7250d5 | ||
|
|
a850d5dba7 | ||
|
|
0cf0f10654 | ||
|
|
8429f154ca | ||
|
|
7b4f5ad362 | ||
|
|
583b439557 | ||
|
|
05922de102 | ||
|
|
7675a44b90 | ||
|
|
1e4d645683 | ||
|
|
b5ae04605a | ||
|
|
2240d6b94c | ||
|
|
d1536ee636 | ||
|
|
8a926add7a | ||
|
|
31f769900a | ||
|
|
33ad777664 | ||
|
|
59a4e4a337 | ||
|
|
66a39933b0 | ||
|
|
ad395e3bba | ||
|
|
cfc6f2c229 | ||
|
|
63aa41c766 | ||
|
|
037e0e93d3 | ||
|
|
db8b5865b3 | ||
|
|
bd2ccc6672 | ||
|
|
bb63d40cdf | ||
|
|
65285b8885 | ||
|
|
326b8f2b4f | ||
|
|
9f3df52fcc | ||
|
|
875838c277 | ||
|
|
adaafd1fda | ||
|
|
50c5efddaa | ||
|
|
c4be054161 | ||
|
|
61186356f3 | ||
|
|
9d60a19440 | ||
|
|
108c212855 | ||
|
|
ae8db81c4e | ||
|
|
51c970d1d0 |
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -37,7 +37,7 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
CACHE_VERSION: 1
|
CACHE_VERSION: 2
|
||||||
UV_CACHE_VERSION: 1
|
UV_CACHE_VERSION: 1
|
||||||
MYPY_CACHE_VERSION: 1
|
MYPY_CACHE_VERSION: 1
|
||||||
HA_SHORT_VERSION: "2025.12"
|
HA_SHORT_VERSION: "2025.12"
|
||||||
@@ -622,7 +622,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- *checkout
|
- *checkout
|
||||||
- name: Dependency review
|
- name: Dependency review
|
||||||
uses: actions/dependency-review-action@40c09b7dc99638e5ddb0bfd91c1673effc064d8a # v4.8.1
|
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||||
with:
|
with:
|
||||||
license-check: false # We use our own license audit checks
|
license-check: false # We use our own license audit checks
|
||||||
|
|
||||||
|
|||||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1017,8 +1017,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/msteams/ @peroyvind
|
/homeassistant/components/msteams/ @peroyvind
|
||||||
/homeassistant/components/mullvad/ @meichthys
|
/homeassistant/components/mullvad/ @meichthys
|
||||||
/tests/components/mullvad/ @meichthys
|
/tests/components/mullvad/ @meichthys
|
||||||
/homeassistant/components/music_assistant/ @music-assistant
|
/homeassistant/components/music_assistant/ @music-assistant @arturpragacz
|
||||||
/tests/components/music_assistant/ @music-assistant
|
/tests/components/music_assistant/ @music-assistant @arturpragacz
|
||||||
/homeassistant/components/mutesync/ @currentoor
|
/homeassistant/components/mutesync/ @currentoor
|
||||||
/tests/components/mutesync/ @currentoor
|
/tests/components/mutesync/ @currentoor
|
||||||
/homeassistant/components/my/ @home-assistant/core
|
/homeassistant/components/my/ @home-assistant/core
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ from .const import (
|
|||||||
RECOMMENDED_CHAT_MODEL,
|
RECOMMENDED_CHAT_MODEL,
|
||||||
)
|
)
|
||||||
|
|
||||||
PLATFORMS = (Platform.CONVERSATION,)
|
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION)
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
type AnthropicConfigEntry = ConfigEntry[anthropic.AsyncClient]
|
||||||
|
|||||||
80
homeassistant/components/anthropic/ai_task.py
Normal file
80
homeassistant/components/anthropic/ai_task.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""AI Task integration for Anthropic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from json import JSONDecodeError
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components import ai_task, conversation
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
from homeassistant.util.json import json_loads
|
||||||
|
|
||||||
|
from .entity import AnthropicBaseLLMEntity
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up AI Task entities."""
|
||||||
|
for subentry in config_entry.subentries.values():
|
||||||
|
if subentry.subentry_type != "ai_task_data":
|
||||||
|
continue
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
[AnthropicTaskEntity(config_entry, subentry)],
|
||||||
|
config_subentry_id=subentry.subentry_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicTaskEntity(
|
||||||
|
ai_task.AITaskEntity,
|
||||||
|
AnthropicBaseLLMEntity,
|
||||||
|
):
|
||||||
|
"""Anthropic AI Task entity."""
|
||||||
|
|
||||||
|
_attr_supported_features = (
|
||||||
|
ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||||
|
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_generate_data(
|
||||||
|
self,
|
||||||
|
task: ai_task.GenDataTask,
|
||||||
|
chat_log: conversation.ChatLog,
|
||||||
|
) -> ai_task.GenDataTaskResult:
|
||||||
|
"""Handle a generate data task."""
|
||||||
|
await self._async_handle_chat_log(chat_log, task.name, task.structure)
|
||||||
|
|
||||||
|
if not isinstance(chat_log.content[-1], conversation.AssistantContent):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Last content in chat log is not an AssistantContent"
|
||||||
|
)
|
||||||
|
|
||||||
|
text = chat_log.content[-1].content or ""
|
||||||
|
|
||||||
|
if not task.structure:
|
||||||
|
return ai_task.GenDataTaskResult(
|
||||||
|
conversation_id=chat_log.conversation_id,
|
||||||
|
data=text,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
data = json_loads(text)
|
||||||
|
except JSONDecodeError as err:
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to parse JSON response: %s. Response: %s",
|
||||||
|
err,
|
||||||
|
text,
|
||||||
|
)
|
||||||
|
raise HomeAssistantError("Error with Claude structured response") from err
|
||||||
|
|
||||||
|
return ai_task.GenDataTaskResult(
|
||||||
|
conversation_id=chat_log.conversation_id,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
@@ -53,6 +53,7 @@ from .const import (
|
|||||||
CONF_WEB_SEARCH_REGION,
|
CONF_WEB_SEARCH_REGION,
|
||||||
CONF_WEB_SEARCH_TIMEZONE,
|
CONF_WEB_SEARCH_TIMEZONE,
|
||||||
CONF_WEB_SEARCH_USER_LOCATION,
|
CONF_WEB_SEARCH_USER_LOCATION,
|
||||||
|
DEFAULT_AI_TASK_NAME,
|
||||||
DEFAULT_CONVERSATION_NAME,
|
DEFAULT_CONVERSATION_NAME,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
NON_THINKING_MODELS,
|
NON_THINKING_MODELS,
|
||||||
@@ -74,12 +75,16 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
RECOMMENDED_OPTIONS = {
|
RECOMMENDED_CONVERSATION_OPTIONS = {
|
||||||
CONF_RECOMMENDED: True,
|
CONF_RECOMMENDED: True,
|
||||||
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
CONF_LLM_HASS_API: [llm.LLM_API_ASSIST],
|
||||||
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
CONF_PROMPT: llm.DEFAULT_INSTRUCTIONS_PROMPT,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RECOMMENDED_AI_TASK_OPTIONS = {
|
||||||
|
CONF_RECOMMENDED: True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||||
"""Validate the user input allows us to connect.
|
"""Validate the user input allows us to connect.
|
||||||
@@ -102,7 +107,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle the initial step."""
|
"""Handle the initial step."""
|
||||||
errors = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
self._async_abort_entries_match(user_input)
|
self._async_abort_entries_match(user_input)
|
||||||
@@ -130,10 +135,16 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
subentries=[
|
subentries=[
|
||||||
{
|
{
|
||||||
"subentry_type": "conversation",
|
"subentry_type": "conversation",
|
||||||
"data": RECOMMENDED_OPTIONS,
|
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||||
"title": DEFAULT_CONVERSATION_NAME,
|
"title": DEFAULT_CONVERSATION_NAME,
|
||||||
"unique_id": None,
|
"unique_id": None,
|
||||||
}
|
},
|
||||||
|
{
|
||||||
|
"subentry_type": "ai_task_data",
|
||||||
|
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||||
|
"title": DEFAULT_AI_TASK_NAME,
|
||||||
|
"unique_id": None,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,7 +158,10 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
cls, config_entry: ConfigEntry
|
cls, config_entry: ConfigEntry
|
||||||
) -> dict[str, type[ConfigSubentryFlow]]:
|
) -> dict[str, type[ConfigSubentryFlow]]:
|
||||||
"""Return subentries supported by this integration."""
|
"""Return subentries supported by this integration."""
|
||||||
return {"conversation": ConversationSubentryFlowHandler}
|
return {
|
||||||
|
"conversation": ConversationSubentryFlowHandler,
|
||||||
|
"ai_task_data": ConversationSubentryFlowHandler,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||||
@@ -164,7 +178,10 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> SubentryFlowResult:
|
) -> SubentryFlowResult:
|
||||||
"""Add a subentry."""
|
"""Add a subentry."""
|
||||||
self.options = RECOMMENDED_OPTIONS.copy()
|
if self._subentry_type == "ai_task_data":
|
||||||
|
self.options = RECOMMENDED_AI_TASK_OPTIONS.copy()
|
||||||
|
else:
|
||||||
|
self.options = RECOMMENDED_CONVERSATION_OPTIONS.copy()
|
||||||
return await self.async_step_init()
|
return await self.async_step_init()
|
||||||
|
|
||||||
async def async_step_reconfigure(
|
async def async_step_reconfigure(
|
||||||
@@ -198,10 +215,13 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if self._is_new:
|
if self._is_new:
|
||||||
step_schema[vol.Required(CONF_NAME, default=DEFAULT_CONVERSATION_NAME)] = (
|
if self._subentry_type == "ai_task_data":
|
||||||
str
|
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(
|
step_schema.update(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
vol.Optional(CONF_PROMPT): TemplateSelector(),
|
||||||
@@ -210,12 +230,15 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
): SelectSelector(
|
): SelectSelector(
|
||||||
SelectSelectorConfig(options=hass_apis, multiple=True)
|
SelectSelectorConfig(options=hass_apis, multiple=True)
|
||||||
),
|
),
|
||||||
vol.Required(
|
|
||||||
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
|
|
||||||
): bool,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
step_schema[
|
||||||
|
vol.Required(
|
||||||
|
CONF_RECOMMENDED, default=self.options.get(CONF_RECOMMENDED, False)
|
||||||
|
)
|
||||||
|
] = bool
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
if not user_input.get(CONF_LLM_HASS_API):
|
if not user_input.get(CONF_LLM_HASS_API):
|
||||||
user_input.pop(CONF_LLM_HASS_API, None)
|
user_input.pop(CONF_LLM_HASS_API, None)
|
||||||
@@ -298,10 +321,14 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
|||||||
if not model.startswith(tuple(NON_THINKING_MODELS)):
|
if not model.startswith(tuple(NON_THINKING_MODELS)):
|
||||||
step_schema[
|
step_schema[
|
||||||
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
|
vol.Optional(CONF_THINKING_BUDGET, default=RECOMMENDED_THINKING_BUDGET)
|
||||||
] = NumberSelector(
|
] = vol.All(
|
||||||
|
NumberSelector(
|
||||||
NumberSelectorConfig(
|
NumberSelectorConfig(
|
||||||
min=0, max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS)
|
min=0,
|
||||||
|
max=self.options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
vol.Coerce(int),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.options.pop(CONF_THINKING_BUDGET, None)
|
self.options.pop(CONF_THINKING_BUDGET, None)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ DOMAIN = "anthropic"
|
|||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|
||||||
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
DEFAULT_CONVERSATION_NAME = "Claude conversation"
|
||||||
|
DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||||
|
|
||||||
CONF_RECOMMENDED = "recommended"
|
CONF_RECOMMENDED = "recommended"
|
||||||
CONF_PROMPT = "prompt"
|
CONF_PROMPT = "prompt"
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
"""Base entity for Anthropic."""
|
"""Base entity for Anthropic."""
|
||||||
|
|
||||||
|
import base64
|
||||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
import json
|
||||||
|
from mimetypes import guess_file_type
|
||||||
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
from anthropic import AsyncStream
|
from anthropic import AsyncStream
|
||||||
from anthropic.types import (
|
from anthropic.types import (
|
||||||
|
Base64ImageSourceParam,
|
||||||
|
Base64PDFSourceParam,
|
||||||
CitationsDelta,
|
CitationsDelta,
|
||||||
CitationsWebSearchResultLocation,
|
CitationsWebSearchResultLocation,
|
||||||
CitationWebSearchResultLocationParam,
|
CitationWebSearchResultLocationParam,
|
||||||
ContentBlockParam,
|
ContentBlockParam,
|
||||||
|
DocumentBlockParam,
|
||||||
|
ImageBlockParam,
|
||||||
InputJSONDelta,
|
InputJSONDelta,
|
||||||
MessageDeltaUsage,
|
MessageDeltaUsage,
|
||||||
MessageParam,
|
MessageParam,
|
||||||
@@ -37,6 +44,9 @@ from anthropic.types import (
|
|||||||
ThinkingConfigDisabledParam,
|
ThinkingConfigDisabledParam,
|
||||||
ThinkingConfigEnabledParam,
|
ThinkingConfigEnabledParam,
|
||||||
ThinkingDelta,
|
ThinkingDelta,
|
||||||
|
ToolChoiceAnyParam,
|
||||||
|
ToolChoiceAutoParam,
|
||||||
|
ToolChoiceToolParam,
|
||||||
ToolParam,
|
ToolParam,
|
||||||
ToolResultBlockParam,
|
ToolResultBlockParam,
|
||||||
ToolUnionParam,
|
ToolUnionParam,
|
||||||
@@ -50,13 +60,16 @@ from anthropic.types import (
|
|||||||
WebSearchToolResultError,
|
WebSearchToolResultError,
|
||||||
)
|
)
|
||||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||||
|
import voluptuous as vol
|
||||||
from voluptuous_openapi import convert
|
from voluptuous_openapi import convert
|
||||||
|
|
||||||
from homeassistant.components import conversation
|
from homeassistant.components import conversation
|
||||||
from homeassistant.config_entries import ConfigSubentry
|
from homeassistant.config_entries import ConfigSubentry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import device_registry as dr, llm
|
from homeassistant.helpers import device_registry as dr, llm
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
from homeassistant.util import slugify
|
||||||
|
|
||||||
from . import AnthropicConfigEntry
|
from . import AnthropicConfigEntry
|
||||||
from .const import (
|
from .const import (
|
||||||
@@ -321,6 +334,7 @@ def _convert_content(
|
|||||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||||
chat_log: conversation.ChatLog,
|
chat_log: conversation.ChatLog,
|
||||||
stream: AsyncStream[MessageStreamEvent],
|
stream: AsyncStream[MessageStreamEvent],
|
||||||
|
output_tool: str | None = None,
|
||||||
) -> AsyncGenerator[
|
) -> AsyncGenerator[
|
||||||
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
conversation.AssistantContentDeltaDict | conversation.ToolResultContentDeltaDict
|
||||||
]:
|
]:
|
||||||
@@ -381,6 +395,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
|||||||
input="",
|
input="",
|
||||||
)
|
)
|
||||||
current_tool_args = ""
|
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):
|
elif isinstance(response.content_block, TextBlock):
|
||||||
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
if ( # Do not start a new assistant content just for citations, concatenate consecutive blocks with citations instead.
|
||||||
first_block
|
first_block
|
||||||
@@ -471,6 +495,15 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
|||||||
first_block = True
|
first_block = True
|
||||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||||
if isinstance(response.delta, InputJSONDelta):
|
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):
|
elif isinstance(response.delta, TextDelta):
|
||||||
content_details.citation_details[-1].length += len(response.delta.text)
|
content_details.citation_details[-1].length += len(response.delta.text)
|
||||||
@@ -490,6 +523,9 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
|||||||
content_details.add_citation(response.delta.citation)
|
content_details.add_citation(response.delta.citation)
|
||||||
elif isinstance(response, RawContentBlockStopEvent):
|
elif isinstance(response, RawContentBlockStopEvent):
|
||||||
if current_tool_block is not None:
|
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 {}
|
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||||
current_tool_block["input"] = tool_args
|
current_tool_block["input"] = tool_args
|
||||||
yield {
|
yield {
|
||||||
@@ -557,6 +593,8 @@ class AnthropicBaseLLMEntity(Entity):
|
|||||||
async def _async_handle_chat_log(
|
async def _async_handle_chat_log(
|
||||||
self,
|
self,
|
||||||
chat_log: conversation.ChatLog,
|
chat_log: conversation.ChatLog,
|
||||||
|
structure_name: str | None = None,
|
||||||
|
structure: vol.Schema | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Generate an answer for the chat log."""
|
"""Generate an answer for the chat log."""
|
||||||
options = self.subentry.data
|
options = self.subentry.data
|
||||||
@@ -613,6 +651,74 @@ class AnthropicBaseLLMEntity(Entity):
|
|||||||
}
|
}
|
||||||
tools.append(web_search)
|
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:
|
if tools:
|
||||||
model_args["tools"] = tools
|
model_args["tools"] = tools
|
||||||
|
|
||||||
@@ -629,7 +735,11 @@ class AnthropicBaseLLMEntity(Entity):
|
|||||||
content
|
content
|
||||||
async for content in chat_log.async_add_delta_content_stream(
|
async for content in chat_log.async_add_delta_content_stream(
|
||||||
self.entity_id,
|
self.entity_id,
|
||||||
_transform_stream(chat_log, stream),
|
_transform_stream(
|
||||||
|
chat_log,
|
||||||
|
stream,
|
||||||
|
output_tool=structure_name if structure else None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -641,3 +751,59 @@ class AnthropicBaseLLMEntity(Entity):
|
|||||||
|
|
||||||
if not chat_log.unresponded_tool_results:
|
if not chat_log.unresponded_tool_results:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
|
async def async_prepare_files_for_prompt(
|
||||||
|
hass: HomeAssistant, files: list[tuple[Path, str | None]]
|
||||||
|
) -> Iterable[ImageBlockParam | DocumentBlockParam]:
|
||||||
|
"""Append files to a prompt.
|
||||||
|
|
||||||
|
Caller needs to ensure that the files are allowed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def append_files_to_content() -> Iterable[ImageBlockParam | DocumentBlockParam]:
|
||||||
|
content: list[ImageBlockParam | DocumentBlockParam] = []
|
||||||
|
|
||||||
|
for file_path, mime_type in files:
|
||||||
|
if not file_path.exists():
|
||||||
|
raise HomeAssistantError(f"`{file_path}` does not exist")
|
||||||
|
|
||||||
|
if mime_type is None:
|
||||||
|
mime_type = guess_file_type(file_path)[0]
|
||||||
|
|
||||||
|
if not mime_type or not mime_type.startswith(("image/", "application/pdf")):
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Only images and PDF are supported by the Anthropic API,"
|
||||||
|
f"`{file_path}` is not an image file or PDF"
|
||||||
|
)
|
||||||
|
if mime_type == "image/jpg":
|
||||||
|
mime_type = "image/jpeg"
|
||||||
|
|
||||||
|
base64_file = base64.b64encode(file_path.read_bytes()).decode("utf-8")
|
||||||
|
|
||||||
|
if mime_type.startswith("image/"):
|
||||||
|
content.append(
|
||||||
|
ImageBlockParam(
|
||||||
|
type="image",
|
||||||
|
source=Base64ImageSourceParam(
|
||||||
|
type="base64",
|
||||||
|
media_type=mime_type, # type: ignore[typeddict-item]
|
||||||
|
data=base64_file,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif mime_type.startswith("application/pdf"):
|
||||||
|
content.append(
|
||||||
|
DocumentBlockParam(
|
||||||
|
type="document",
|
||||||
|
source=Base64PDFSourceParam(
|
||||||
|
type="base64",
|
||||||
|
media_type=mime_type, # type: ignore[typeddict-item]
|
||||||
|
data=base64_file,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
return await hass.async_add_executor_job(append_files_to_content)
|
||||||
|
|||||||
@@ -18,6 +18,49 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"config_subentries": {
|
"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": {
|
"conversation": {
|
||||||
"abort": {
|
"abort": {
|
||||||
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
|
"entry_not_loaded": "Cannot add things while the configuration is disabled.",
|
||||||
@@ -46,7 +89,8 @@
|
|||||||
},
|
},
|
||||||
"data_description": {
|
"data_description": {
|
||||||
"prompt": "Instruct how the LLM should respond. This can be a template."
|
"prompt": "Instruct how the LLM should respond. This can be a template."
|
||||||
}
|
},
|
||||||
|
"title": "Basic settings"
|
||||||
},
|
},
|
||||||
"model": {
|
"model": {
|
||||||
"data": {
|
"data": {
|
||||||
|
|||||||
@@ -37,13 +37,6 @@ USER_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.string})
|
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]:
|
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||||
@@ -175,19 +168,21 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle reconfiguration of the device."""
|
"""Handle reconfiguration of the device."""
|
||||||
reconfigure_entry = self._get_reconfigure_entry()
|
reconfigure_entry = self._get_reconfigure_entry()
|
||||||
if not user_input:
|
errors: dict[str, str] = {}
|
||||||
return self.async_show_form(
|
|
||||||
step_id="reconfigure", data_schema=STEP_RECONFIGURE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
updated_host = user_input[CONF_HOST]
|
updated_host = user_input[CONF_HOST]
|
||||||
|
|
||||||
self._async_abort_entries_match({CONF_HOST: updated_host})
|
self._async_abort_entries_match({CONF_HOST: updated_host})
|
||||||
|
|
||||||
errors: dict[str, str] = {}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await validate_input(self.hass, user_input)
|
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:
|
except CannotConnect:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except InvalidAuth:
|
except InvalidAuth:
|
||||||
@@ -198,13 +193,30 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
_LOGGER.exception("Unexpected exception")
|
_LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
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(
|
return self.async_update_reload_and_abort(
|
||||||
reconfigure_entry, data_updates={CONF_HOST: updated_host}
|
reconfigure_entry, data_updates=data_updates
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required(
|
||||||
|
CONF_HOST, default=reconfigure_entry.data[CONF_HOST]
|
||||||
|
): cv.string,
|
||||||
|
vol.Required(
|
||||||
|
CONF_PORT, default=reconfigure_entry.data[CONF_PORT]
|
||||||
|
): cv.port,
|
||||||
|
vol.Optional(CONF_PIN): cv.string,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reconfigure",
|
step_id="reconfigure",
|
||||||
data_schema=STEP_RECONFIGURE,
|
data_schema=schema,
|
||||||
errors=errors,
|
errors=errors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
import logging
|
import logging
|
||||||
from typing import Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from hassil.recognize import RecognizeResult
|
from hassil.recognize import RecognizeResult
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
@@ -21,6 +21,7 @@ from homeassistant.core import (
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import config_validation as cv, intent
|
from homeassistant.helpers import config_validation as cv, intent
|
||||||
from homeassistant.helpers.entity_component import EntityComponent
|
from homeassistant.helpers.entity_component import EntityComponent
|
||||||
|
from homeassistant.helpers.reload import async_integration_yaml_config
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.loader import bind_hass
|
from homeassistant.loader import bind_hass
|
||||||
|
|
||||||
@@ -52,6 +53,8 @@ from .const import (
|
|||||||
DATA_COMPONENT,
|
DATA_COMPONENT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
HOME_ASSISTANT_AGENT,
|
HOME_ASSISTANT_AGENT,
|
||||||
|
METADATA_CUSTOM_FILE,
|
||||||
|
METADATA_CUSTOM_SENTENCE,
|
||||||
SERVICE_PROCESS,
|
SERVICE_PROCESS,
|
||||||
SERVICE_RELOAD,
|
SERVICE_RELOAD,
|
||||||
ConversationEntityFeature,
|
ConversationEntityFeature,
|
||||||
@@ -266,10 +269,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
|
entity_component = EntityComponent[ConversationEntity](_LOGGER, DOMAIN, hass)
|
||||||
hass.data[DATA_COMPONENT] = entity_component
|
hass.data[DATA_COMPONENT] = entity_component
|
||||||
|
|
||||||
agent_config = config.get(DOMAIN, {})
|
manager = get_agent_manager(hass)
|
||||||
await async_setup_default_agent(
|
|
||||||
hass, entity_component, config_intents=agent_config.get("intents", {})
|
hass_config_path = hass.config.path()
|
||||||
)
|
config_intents = _get_config_intents(config, hass_config_path)
|
||||||
|
manager.update_config_intents(config_intents)
|
||||||
|
|
||||||
|
await async_setup_default_agent(hass, entity_component)
|
||||||
|
|
||||||
async def handle_process(service: ServiceCall) -> ServiceResponse:
|
async def handle_process(service: ServiceCall) -> ServiceResponse:
|
||||||
"""Parse text into commands."""
|
"""Parse text into commands."""
|
||||||
@@ -294,9 +300,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
async def handle_reload(service: ServiceCall) -> None:
|
async def handle_reload(service: ServiceCall) -> None:
|
||||||
"""Reload intents."""
|
"""Reload intents."""
|
||||||
agent = get_agent_manager(hass).default_agent
|
language = service.data.get(ATTR_LANGUAGE)
|
||||||
|
if language is None:
|
||||||
|
conf = await async_integration_yaml_config(hass, DOMAIN)
|
||||||
|
if conf is not None:
|
||||||
|
config_intents = _get_config_intents(conf, hass_config_path)
|
||||||
|
manager.update_config_intents(config_intents)
|
||||||
|
|
||||||
|
agent = manager.default_agent
|
||||||
if agent is not None:
|
if agent is not None:
|
||||||
await agent.async_reload(language=service.data.get(ATTR_LANGUAGE))
|
await agent.async_reload(language=language)
|
||||||
|
|
||||||
hass.services.async_register(
|
hass.services.async_register(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@@ -313,6 +326,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
return True
|
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:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up a config entry."""
|
"""Set up a config entry."""
|
||||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ class AgentManager:
|
|||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._agents: dict[str, AbstractConversationAgent] = {}
|
self._agents: dict[str, AbstractConversationAgent] = {}
|
||||||
self.default_agent: DefaultAgent | None = None
|
self.default_agent: DefaultAgent | None = None
|
||||||
|
self.config_intents: dict[str, Any] = {}
|
||||||
self.triggers_details: list[TriggerDetails] = []
|
self.triggers_details: list[TriggerDetails] = []
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@@ -199,9 +200,16 @@ class AgentManager:
|
|||||||
|
|
||||||
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
async def async_setup_default_agent(self, agent: DefaultAgent) -> None:
|
||||||
"""Set up the default agent."""
|
"""Set up the default agent."""
|
||||||
|
agent.update_config_intents(self.config_intents)
|
||||||
agent.update_triggers(self.triggers_details)
|
agent.update_triggers(self.triggers_details)
|
||||||
self.default_agent = agent
|
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:
|
def register_trigger(self, trigger_details: TriggerDetails) -> CALLBACK_TYPE:
|
||||||
"""Register a trigger."""
|
"""Register a trigger."""
|
||||||
self.triggers_details.append(trigger_details)
|
self.triggers_details.append(trigger_details)
|
||||||
|
|||||||
@@ -30,3 +30,7 @@ class ConversationEntityFeature(IntFlag):
|
|||||||
"""Supported features of the conversation entity."""
|
"""Supported features of the conversation entity."""
|
||||||
|
|
||||||
CONTROL = 1
|
CONTROL = 1
|
||||||
|
|
||||||
|
|
||||||
|
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
||||||
|
METADATA_CUSTOM_FILE = "hass_custom_file"
|
||||||
|
|||||||
@@ -77,7 +77,12 @@ from homeassistant.util.json import JsonObjectType, json_loads_object
|
|||||||
|
|
||||||
from .agent_manager import get_agent_manager
|
from .agent_manager import get_agent_manager
|
||||||
from .chat_log import AssistantContent, ChatLog
|
from .chat_log import AssistantContent, ChatLog
|
||||||
from .const import DOMAIN, ConversationEntityFeature
|
from .const import (
|
||||||
|
DOMAIN,
|
||||||
|
METADATA_CUSTOM_FILE,
|
||||||
|
METADATA_CUSTOM_SENTENCE,
|
||||||
|
ConversationEntityFeature,
|
||||||
|
)
|
||||||
from .entity import ConversationEntity
|
from .entity import ConversationEntity
|
||||||
from .models import ConversationInput, ConversationResult
|
from .models import ConversationInput, ConversationResult
|
||||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||||
@@ -91,8 +96,6 @@ _ENTITY_REGISTRY_UPDATE_FIELDS = ["aliases", "name", "original_name"]
|
|||||||
|
|
||||||
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
_DEFAULT_EXPOSED_ATTRIBUTES = {"device_class"}
|
||||||
|
|
||||||
METADATA_CUSTOM_SENTENCE = "hass_custom_sentence"
|
|
||||||
METADATA_CUSTOM_FILE = "hass_custom_file"
|
|
||||||
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
METADATA_FUZZY_MATCH = "hass_fuzzy_match"
|
||||||
|
|
||||||
ERROR_SENTINEL = object()
|
ERROR_SENTINEL = object()
|
||||||
@@ -202,10 +205,9 @@ class IntentCache:
|
|||||||
async def async_setup_default_agent(
|
async def async_setup_default_agent(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entity_component: EntityComponent[ConversationEntity],
|
entity_component: EntityComponent[ConversationEntity],
|
||||||
config_intents: dict[str, Any],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up entity registry listener for the default agent."""
|
"""Set up entity registry listener for the default agent."""
|
||||||
agent = DefaultAgent(hass, config_intents)
|
agent = DefaultAgent(hass)
|
||||||
await entity_component.async_add_entities([agent])
|
await entity_component.async_add_entities([agent])
|
||||||
await get_agent_manager(hass).async_setup_default_agent(agent)
|
await get_agent_manager(hass).async_setup_default_agent(agent)
|
||||||
|
|
||||||
@@ -230,14 +232,14 @@ class DefaultAgent(ConversationEntity):
|
|||||||
_attr_name = "Home Assistant"
|
_attr_name = "Home Assistant"
|
||||||
_attr_supported_features = ConversationEntityFeature.CONTROL
|
_attr_supported_features = ConversationEntityFeature.CONTROL
|
||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, config_intents: dict[str, Any]) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the default agent."""
|
"""Initialize the default agent."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
self._lang_intents: dict[str, LanguageIntents | object] = {}
|
||||||
self._load_intents_lock = asyncio.Lock()
|
self._load_intents_lock = asyncio.Lock()
|
||||||
|
|
||||||
# intent -> [sentences]
|
# Intents from common conversation config
|
||||||
self._config_intents: dict[str, Any] = config_intents
|
self._config_intents: dict[str, Any] = {}
|
||||||
|
|
||||||
# Sentences that will trigger a callback (skipping intent recognition)
|
# Sentences that will trigger a callback (skipping intent recognition)
|
||||||
self._triggers_details: list[TriggerDetails] = []
|
self._triggers_details: list[TriggerDetails] = []
|
||||||
@@ -1035,6 +1037,14 @@ class DefaultAgent(ConversationEntity):
|
|||||||
# Intents have changed, so we must clear the cache
|
# Intents have changed, so we must clear the cache
|
||||||
self._intent_cache.clear()
|
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:
|
async def async_prepare(self, language: str | None = None) -> None:
|
||||||
"""Load intents for a language."""
|
"""Load intents for a language."""
|
||||||
if language is None:
|
if language is None:
|
||||||
@@ -1159,32 +1169,9 @@ class DefaultAgent(ConversationEntity):
|
|||||||
custom_sentences_path,
|
custom_sentences_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Load sentences from HA config for default language only
|
|
||||||
if self._config_intents and (
|
|
||||||
self.hass.config.language in (language, language_variant)
|
|
||||||
):
|
|
||||||
hass_config_path = self.hass.config.path()
|
|
||||||
merge_dict(
|
merge_dict(
|
||||||
intents_dict,
|
intents_dict,
|
||||||
{
|
self._config_intents,
|
||||||
"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:
|
if not intents_dict:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
},
|
},
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["pyecobee"],
|
"loggers": ["pyecobee"],
|
||||||
"requirements": ["python-ecobee-api==0.2.20"],
|
"requirements": ["python-ecobee-api==0.3.2"],
|
||||||
"single_config_entry": true,
|
"single_config_entry": true,
|
||||||
"zeroconf": [
|
"zeroconf": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Literal, TypedDict
|
from typing import Literal, NotRequired, TypedDict
|
||||||
|
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
|
|||||||
class FlowFromGridSourceType(TypedDict):
|
class FlowFromGridSourceType(TypedDict):
|
||||||
"""Dictionary describing the 'from' stat for the grid source."""
|
"""Dictionary describing the 'from' stat for the grid source."""
|
||||||
|
|
||||||
# statistic_id of a an energy meter (kWh)
|
# statistic_id of an energy meter (kWh)
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
|
|
||||||
# statistic_id of costs ($) incurred from the energy meter
|
# statistic_id of costs ($) incurred from the energy meter
|
||||||
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
|
|||||||
number_energy_price: float | None # Price for energy ($/kWh)
|
number_energy_price: float | None # Price for energy ($/kWh)
|
||||||
|
|
||||||
|
|
||||||
|
class GridPowerSourceType(TypedDict):
|
||||||
|
"""Dictionary holding the source of grid power consumption."""
|
||||||
|
|
||||||
|
# statistic_id of a power meter (kW)
|
||||||
|
# negative values indicate grid return
|
||||||
|
stat_rate: str
|
||||||
|
|
||||||
|
|
||||||
class GridSourceType(TypedDict):
|
class GridSourceType(TypedDict):
|
||||||
"""Dictionary holding the source of grid energy consumption."""
|
"""Dictionary holding the source of grid energy consumption."""
|
||||||
|
|
||||||
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
|
|||||||
|
|
||||||
flow_from: list[FlowFromGridSourceType]
|
flow_from: list[FlowFromGridSourceType]
|
||||||
flow_to: list[FlowToGridSourceType]
|
flow_to: list[FlowToGridSourceType]
|
||||||
|
power: NotRequired[list[GridPowerSourceType]]
|
||||||
|
|
||||||
cost_adjustment_day: float
|
cost_adjustment_day: float
|
||||||
|
|
||||||
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
|
|||||||
type: Literal["solar"]
|
type: Literal["solar"]
|
||||||
|
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
|
stat_rate: NotRequired[str]
|
||||||
config_entry_solar_forecast: list[str] | None
|
config_entry_solar_forecast: list[str] | None
|
||||||
|
|
||||||
|
|
||||||
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
|
|||||||
|
|
||||||
stat_energy_from: str
|
stat_energy_from: str
|
||||||
stat_energy_to: str
|
stat_energy_to: str
|
||||||
|
# positive when discharging, negative when charging
|
||||||
|
stat_rate: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
class GasSourceType(TypedDict):
|
class GasSourceType(TypedDict):
|
||||||
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
|
|||||||
# This is an ever increasing value
|
# This is an ever increasing value
|
||||||
stat_consumption: str
|
stat_consumption: str
|
||||||
|
|
||||||
|
# Instantaneous rate of flow: W, L/min or m³/h
|
||||||
|
stat_rate: NotRequired[str]
|
||||||
|
|
||||||
# An optional custom name for display in energy graphs
|
# An optional custom name for display in energy graphs
|
||||||
name: str | None
|
name: str | None
|
||||||
|
|
||||||
# An optional statistic_id identifying a device
|
# An optional statistic_id identifying a device
|
||||||
# that includes this device's consumption in its total
|
# that includes this device's consumption in its total
|
||||||
included_in_stat: str | None
|
included_in_stat: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
class EnergyPreferences(TypedDict):
|
class EnergyPreferences(TypedDict):
|
||||||
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
|
||||||
|
{
|
||||||
|
vol.Required("stat_rate"): str,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
|
||||||
"""Generate a validator that ensures a value is only used once."""
|
"""Generate a validator that ensures a value is only used once."""
|
||||||
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
|
|||||||
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
[FLOW_TO_GRID_SOURCE_SCHEMA],
|
||||||
_generate_unique_value_validator("stat_energy_to"),
|
_generate_unique_value_validator("stat_energy_to"),
|
||||||
),
|
),
|
||||||
|
vol.Optional("power"): vol.All(
|
||||||
|
[GRID_POWER_SOURCE_SCHEMA],
|
||||||
|
_generate_unique_value_validator("stat_rate"),
|
||||||
|
),
|
||||||
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
vol.Required("cost_adjustment_day"): vol.Coerce(float),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
|
|||||||
{
|
{
|
||||||
vol.Required("type"): "solar",
|
vol.Required("type"): "solar",
|
||||||
vol.Required("stat_energy_from"): str,
|
vol.Required("stat_energy_from"): str,
|
||||||
|
vol.Optional("stat_rate"): str,
|
||||||
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
|
|||||||
vol.Required("type"): "battery",
|
vol.Required("type"): "battery",
|
||||||
vol.Required("stat_energy_from"): str,
|
vol.Required("stat_energy_from"): str,
|
||||||
vol.Required("stat_energy_to"): str,
|
vol.Required("stat_energy_to"): str,
|
||||||
|
vol.Optional("stat_rate"): str,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
GAS_SOURCE_SCHEMA = vol.Schema(
|
GAS_SOURCE_SCHEMA = vol.Schema(
|
||||||
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
|
|||||||
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Required("stat_consumption"): str,
|
vol.Required("stat_consumption"): str,
|
||||||
|
vol.Optional("stat_rate"): str,
|
||||||
vol.Optional("name"): str,
|
vol.Optional("name"): str,
|
||||||
vol.Optional("included_in_stat"): str,
|
vol.Optional("included_in_stat"): str,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from homeassistant.const import (
|
|||||||
STATE_UNAVAILABLE,
|
STATE_UNAVAILABLE,
|
||||||
STATE_UNKNOWN,
|
STATE_UNKNOWN,
|
||||||
UnitOfEnergy,
|
UnitOfEnergy,
|
||||||
|
UnitOfPower,
|
||||||
UnitOfVolume,
|
UnitOfVolume,
|
||||||
)
|
)
|
||||||
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
from homeassistant.core import HomeAssistant, callback, valid_entity_id
|
||||||
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
|
|||||||
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
|
||||||
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
|
||||||
}
|
}
|
||||||
|
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
|
||||||
|
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
|
||||||
|
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
|
||||||
|
}
|
||||||
|
|
||||||
ENERGY_PRICE_UNITS = tuple(
|
ENERGY_PRICE_UNITS = tuple(
|
||||||
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
|
||||||
)
|
)
|
||||||
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
|
||||||
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
|
||||||
|
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
|
||||||
GAS_USAGE_DEVICE_CLASSES = (
|
GAS_USAGE_DEVICE_CLASSES = (
|
||||||
sensor.SensorDeviceClass.ENERGY,
|
sensor.SensorDeviceClass.ENERGY,
|
||||||
sensor.SensorDeviceClass.GAS,
|
sensor.SensorDeviceClass.GAS,
|
||||||
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
|
|||||||
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
if issue_type == POWER_UNIT_ERROR:
|
||||||
|
return {
|
||||||
|
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
|
||||||
|
}
|
||||||
if issue_type == GAS_UNIT_ERROR:
|
if issue_type == GAS_UNIT_ERROR:
|
||||||
return {
|
return {
|
||||||
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
|
||||||
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
|
|||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_validate_usage_stat(
|
def _async_validate_stat_common(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
stat_id: str,
|
stat_id: str,
|
||||||
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
|
|||||||
allowed_units: Mapping[str, Sequence[str]],
|
allowed_units: Mapping[str, Sequence[str]],
|
||||||
unit_error: str,
|
unit_error: str,
|
||||||
issues: ValidationIssues,
|
issues: ValidationIssues,
|
||||||
) -> None:
|
check_negative: bool = False,
|
||||||
"""Validate a statistic."""
|
) -> str | None:
|
||||||
|
"""Validate common aspects of a statistic.
|
||||||
|
|
||||||
|
Returns the entity_id if validation succeeds, None otherwise.
|
||||||
|
"""
|
||||||
if stat_id not in metadata:
|
if stat_id not in metadata:
|
||||||
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
issues.add_issue(hass, "statistics_not_defined", stat_id)
|
||||||
|
|
||||||
has_entity_source = valid_entity_id(stat_id)
|
has_entity_source = valid_entity_id(stat_id)
|
||||||
|
|
||||||
if not has_entity_source:
|
if not has_entity_source:
|
||||||
return
|
return None
|
||||||
|
|
||||||
entity_id = stat_id
|
entity_id = stat_id
|
||||||
|
|
||||||
if not recorder.is_entity_recorded(hass, entity_id):
|
if not recorder.is_entity_recorded(hass, entity_id):
|
||||||
issues.add_issue(hass, "recorder_untracked", entity_id)
|
issues.add_issue(hass, "recorder_untracked", entity_id)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if (state := hass.states.get(entity_id)) is None:
|
if (state := hass.states.get(entity_id)) is None:
|
||||||
issues.add_issue(hass, "entity_not_defined", entity_id)
|
issues.add_issue(hass, "entity_not_defined", entity_id)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
|
||||||
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
|
||||||
return
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
current_value: float | None = float(state.state)
|
current_value: float | None = float(state.state)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
|
||||||
return
|
return None
|
||||||
|
|
||||||
if current_value is not None and current_value < 0:
|
if check_negative and current_value is not None and current_value < 0:
|
||||||
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
|
||||||
|
|
||||||
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||||
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
|
|||||||
if device_class and unit not in allowed_units.get(device_class, []):
|
if device_class and unit not in allowed_units.get(device_class, []):
|
||||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||||
|
|
||||||
|
return entity_id
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_validate_usage_stat(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
stat_id: str,
|
||||||
|
allowed_device_classes: Sequence[str],
|
||||||
|
allowed_units: Mapping[str, Sequence[str]],
|
||||||
|
unit_error: str,
|
||||||
|
issues: ValidationIssues,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a statistic."""
|
||||||
|
entity_id = _async_validate_stat_common(
|
||||||
|
hass,
|
||||||
|
metadata,
|
||||||
|
stat_id,
|
||||||
|
allowed_device_classes,
|
||||||
|
allowed_units,
|
||||||
|
unit_error,
|
||||||
|
issues,
|
||||||
|
check_negative=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||||
|
|
||||||
allowed_state_classes = [
|
allowed_state_classes = [
|
||||||
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
|
|||||||
issues.add_issue(hass, unit_error, entity_id, unit)
|
issues.add_issue(hass, unit_error, entity_id, unit)
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _async_validate_power_stat(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
|
||||||
|
stat_id: str,
|
||||||
|
allowed_device_classes: Sequence[str],
|
||||||
|
allowed_units: Mapping[str, Sequence[str]],
|
||||||
|
unit_error: str,
|
||||||
|
issues: ValidationIssues,
|
||||||
|
) -> None:
|
||||||
|
"""Validate a power statistic."""
|
||||||
|
entity_id = _async_validate_stat_common(
|
||||||
|
hass,
|
||||||
|
metadata,
|
||||||
|
stat_id,
|
||||||
|
allowed_device_classes,
|
||||||
|
allowed_units,
|
||||||
|
unit_error,
|
||||||
|
issues,
|
||||||
|
check_negative=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if entity_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
|
||||||
|
|
||||||
|
if state_class != sensor.SensorStateClass.MEASUREMENT:
|
||||||
|
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _async_validate_cost_stat(
|
def _async_validate_cost_stat(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -434,6 +511,21 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for power_stat in source.get("power", []):
|
||||||
|
wanted_statistics_metadata.add(power_stat["stat_rate"])
|
||||||
|
validate_calls.append(
|
||||||
|
functools.partial(
|
||||||
|
_async_validate_power_stat,
|
||||||
|
hass,
|
||||||
|
statistics_metadata,
|
||||||
|
power_stat["stat_rate"],
|
||||||
|
POWER_USAGE_DEVICE_CLASSES,
|
||||||
|
POWER_USAGE_UNITS,
|
||||||
|
POWER_UNIT_ERROR,
|
||||||
|
source_result,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
elif source["type"] == "gas":
|
elif source["type"] == "gas":
|
||||||
wanted_statistics_metadata.add(source["stat_energy_from"])
|
wanted_statistics_metadata.add(source["stat_energy_from"])
|
||||||
validate_calls.append(
|
validate_calls.append(
|
||||||
|
|||||||
@@ -116,20 +116,28 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
|||||||
is_open_wdr = None
|
is_open_wdr = None
|
||||||
is_open_hdr = None
|
is_open_hdr = None
|
||||||
reserve3 = product_info.get("reserve4")
|
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
|
reserve3_int = int(reserve3) if reserve3 is not None else 0
|
||||||
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
supports_wdr_adjustment_val = bool(int(reserve3_int & 256))
|
||||||
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
supports_hdr_adjustment_val = bool(int(reserve3_int & 128))
|
||||||
if supports_wdr_adjustment_val:
|
if supports_wdr_adjustment_val:
|
||||||
ret_wdr, is_open_wdr_data = self.session.getWdrMode()
|
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
|
mode = (
|
||||||
|
is_open_wdr_data["mode"] if ret_wdr == 0 and is_open_wdr_data else 0
|
||||||
|
)
|
||||||
is_open_wdr = bool(int(mode))
|
is_open_wdr = bool(int(mode))
|
||||||
elif supports_hdr_adjustment_val:
|
elif supports_hdr_adjustment_val:
|
||||||
ret_hdr, is_open_hdr_data = self.session.getHdrMode()
|
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
|
mode = (
|
||||||
|
is_open_hdr_data["mode"] if ret_hdr == 0 and is_open_hdr_data else 0
|
||||||
|
)
|
||||||
is_open_hdr = bool(int(mode))
|
is_open_hdr = bool(int(mode))
|
||||||
|
else:
|
||||||
|
supports_wdr_adjustment_val = False
|
||||||
|
supports_hdr_adjustment_val = False
|
||||||
ret_sw, software_capabilities = self.session.getSWCapabilities()
|
ret_sw, software_capabilities = self.session.getSWCapabilities()
|
||||||
|
|
||||||
supports_speak_volume_adjustment_val = (
|
supports_speak_volume_adjustment_val = (
|
||||||
bool(int(software_capabilities.get("swCapabilities1")) & 32)
|
bool(int(software_capabilities.get("swCapabilities1")) & 32)
|
||||||
if ret_sw == 0
|
if ret_sw == 0
|
||||||
|
|||||||
@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
sidebar_title="climate",
|
sidebar_title="climate",
|
||||||
sidebar_default_visible=False,
|
sidebar_default_visible=False,
|
||||||
)
|
)
|
||||||
|
async_register_built_in_panel(
|
||||||
|
hass,
|
||||||
|
"home",
|
||||||
|
sidebar_icon="mdi:home",
|
||||||
|
sidebar_title="home",
|
||||||
|
sidebar_default_visible=False,
|
||||||
|
)
|
||||||
|
|
||||||
async_register_built_in_panel(hass, "profile")
|
async_register_built_in_panel(hass, "profile")
|
||||||
|
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/google",
|
"documentation": "https://www.home-assistant.io/integrations/google",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["googleapiclient"],
|
"loggers": ["googleapiclient"],
|
||||||
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
|
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
"""Support for the Hive alarm."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import timedelta
|
|
||||||
|
|
||||||
from homeassistant.components.alarm_control_panel import (
|
|
||||||
AlarmControlPanelEntity,
|
|
||||||
AlarmControlPanelEntityFeature,
|
|
||||||
AlarmControlPanelState,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant
|
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
|
||||||
|
|
||||||
from . import HiveConfigEntry
|
|
||||||
from .entity import HiveEntity
|
|
||||||
|
|
||||||
PARALLEL_UPDATES = 0
|
|
||||||
SCAN_INTERVAL = timedelta(seconds=15)
|
|
||||||
HIVETOHA = {
|
|
||||||
"home": AlarmControlPanelState.DISARMED,
|
|
||||||
"asleep": AlarmControlPanelState.ARMED_NIGHT,
|
|
||||||
"away": AlarmControlPanelState.ARMED_AWAY,
|
|
||||||
"sos": AlarmControlPanelState.TRIGGERED,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
|
||||||
hass: HomeAssistant,
|
|
||||||
entry: HiveConfigEntry,
|
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
|
||||||
) -> None:
|
|
||||||
"""Set up Hive thermostat based on a config entry."""
|
|
||||||
|
|
||||||
hive = entry.runtime_data
|
|
||||||
if devices := hive.session.deviceList.get("alarm_control_panel"):
|
|
||||||
async_add_entities(
|
|
||||||
[HiveAlarmControlPanelEntity(hive, dev) for dev in devices], True
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity):
|
|
||||||
"""Representation of a Hive alarm."""
|
|
||||||
|
|
||||||
_attr_supported_features = (
|
|
||||||
AlarmControlPanelEntityFeature.ARM_NIGHT
|
|
||||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
|
||||||
| AlarmControlPanelEntityFeature.TRIGGER
|
|
||||||
)
|
|
||||||
_attr_code_arm_required = False
|
|
||||||
|
|
||||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
|
||||||
"""Send disarm command."""
|
|
||||||
await self.hive.alarm.setMode(self.device, "home")
|
|
||||||
|
|
||||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
|
||||||
"""Send arm night command."""
|
|
||||||
await self.hive.alarm.setMode(self.device, "asleep")
|
|
||||||
|
|
||||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
|
||||||
"""Send arm away command."""
|
|
||||||
await self.hive.alarm.setMode(self.device, "away")
|
|
||||||
|
|
||||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
|
||||||
"""Send alarm trigger command."""
|
|
||||||
await self.hive.alarm.setMode(self.device, "sos")
|
|
||||||
|
|
||||||
async def async_update(self) -> None:
|
|
||||||
"""Update all Node data from Hive."""
|
|
||||||
await self.hive.session.updateData(self.device)
|
|
||||||
self.device = await self.hive.alarm.getAlarm(self.device)
|
|
||||||
self._attr_available = self.device["deviceData"].get("online")
|
|
||||||
if self._attr_available:
|
|
||||||
if self.device["status"]["state"]:
|
|
||||||
self._attr_alarm_state = AlarmControlPanelState.TRIGGERED
|
|
||||||
else:
|
|
||||||
self._attr_alarm_state = HIVETOHA[self.device["status"]["mode"]]
|
|
||||||
@@ -11,7 +11,6 @@ CONFIG_ENTRY_VERSION = 1
|
|||||||
DEFAULT_NAME = "Hive"
|
DEFAULT_NAME = "Hive"
|
||||||
DOMAIN = "hive"
|
DOMAIN = "hive"
|
||||||
PLATFORMS = [
|
PLATFORMS = [
|
||||||
Platform.ALARM_CONTROL_PANEL,
|
|
||||||
Platform.BINARY_SENSOR,
|
Platform.BINARY_SENSOR,
|
||||||
Platform.CLIMATE,
|
Platform.CLIMATE,
|
||||||
Platform.LIGHT,
|
Platform.LIGHT,
|
||||||
@@ -20,7 +19,6 @@ PLATFORMS = [
|
|||||||
Platform.WATER_HEATER,
|
Platform.WATER_HEATER,
|
||||||
]
|
]
|
||||||
PLATFORM_LOOKUP = {
|
PLATFORM_LOOKUP = {
|
||||||
Platform.ALARM_CONTROL_PANEL: "alarm_control_panel",
|
|
||||||
Platform.BINARY_SENSOR: "binary_sensor",
|
Platform.BINARY_SENSOR: "binary_sensor",
|
||||||
Platform.CLIMATE: "climate",
|
Platform.CLIMATE: "climate",
|
||||||
Platform.LIGHT: "light",
|
Platform.LIGHT: "light",
|
||||||
|
|||||||
@@ -9,5 +9,5 @@
|
|||||||
},
|
},
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["apyhiveapi"],
|
"loggers": ["apyhiveapi"],
|
||||||
"requirements": ["pyhive-integration==1.0.6"]
|
"requirements": ["pyhive-integration==1.0.7"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,6 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["aiohomeconnect"],
|
"loggers": ["aiohomeconnect"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aiohomeconnect==0.23.0"],
|
"requirements": ["aiohomeconnect==0.23.1"],
|
||||||
"zeroconf": ["_homeconnect._tcp.local."]
|
"zeroconf": ["_homeconnect._tcp.local."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
|||||||
"""Set the program value."""
|
"""Set the program value."""
|
||||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||||
self._attr_current_option = (
|
self._attr_current_option = (
|
||||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
|
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
|
||||||
if event
|
if event and isinstance(event_value := event.value, str)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
|||||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||||
self._update_native_value(status)
|
self._update_native_value(status)
|
||||||
|
|
||||||
def _update_native_value(self, status: str | float) -> None:
|
def _update_native_value(self, status: str | float | None) -> None:
|
||||||
"""Set the value of the sensor based on the given value."""
|
"""Set the value of the sensor based on the given value."""
|
||||||
|
if status is None:
|
||||||
|
self._attr_native_value = None
|
||||||
|
return
|
||||||
match self.device_class:
|
match self.device_class:
|
||||||
case SensorDeviceClass.TIMESTAMP:
|
case SensorDeviceClass.TIMESTAMP:
|
||||||
self._attr_native_value = dt_util.utcnow() + timedelta(
|
self._attr_native_value = dt_util.utcnow() + timedelta(
|
||||||
|
|||||||
@@ -1237,7 +1237,7 @@
|
|||||||
"message": "Error obtaining data from the API: {error}"
|
"message": "Error obtaining data from the API: {error}"
|
||||||
},
|
},
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation temporarily unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
},
|
},
|
||||||
"pause_program": {
|
"pause_program": {
|
||||||
"message": "Error pausing program: {error}"
|
"message": "Error pausing program: {error}"
|
||||||
|
|||||||
@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
|
||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
|
||||||
ZIGBEE_BAUDRATE = 460800
|
ZIGBEE_BAUDRATE = 460800
|
||||||
|
|
||||||
|
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
|
||||||
|
# baudrate method. Since the two are mutually exclusive we just use both.
|
||||||
|
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
]
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantConnectZBT2ConfigEntry
|
from . import HomeAssistantConnectZBT2ConfigEntry
|
||||||
|
from .config_flow import ZBT2FirmwareMixin
|
||||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -134,7 +134,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Connect ZBT-2 firmware update entity."""
|
"""Connect ZBT-2 firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.RTS_DTR]
|
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
|
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
|
|
||||||
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
|
||||||
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
|
||||||
|
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
|
||||||
|
|
||||||
_picked_firmware_type: PickedFirmwareType
|
_picked_firmware_type: PickedFirmwareType
|
||||||
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
|
||||||
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
# Installing new firmware is only truly required if the wrong type is
|
# Installing new firmware is only truly required if the wrong type is
|
||||||
# installed: upgrading to the latest release of the current firmware type
|
# installed: upgrading to the latest release of the current firmware type
|
||||||
# isn't strictly necessary for functionality.
|
# isn't strictly necessary for functionality.
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
firmware_install_required = self._probed_firmware_info is None or (
|
firmware_install_required = self._probed_firmware_info is None or (
|
||||||
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
|
||||||
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
|||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=expected_installed_firmware_type,
|
expected_installed_firmware_type=expected_installed_firmware_type,
|
||||||
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
progress_callback=lambda offset, total: self.async_update_progress(
|
progress_callback=lambda offset, total: self.async_update_progress(
|
||||||
offset / total
|
offset / total
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"universal-silabs-flasher==0.0.37",
|
"universal-silabs-flasher==0.1.0",
|
||||||
"ha-silabs-firmware-client==0.3.0"
|
"ha-silabs-firmware-client==0.3.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
|
|||||||
|
|
||||||
# Subclasses provide the mapping between firmware types and entity descriptions
|
# Subclasses provide the mapping between firmware types and entity descriptions
|
||||||
entity_description: FirmwareUpdateEntityDescription
|
entity_description: FirmwareUpdateEntityDescription
|
||||||
bootloader_reset_methods: list[ResetTarget] = []
|
BOOTLOADER_RESET_METHODS: list[ResetTarget]
|
||||||
|
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
|
||||||
|
|
||||||
_attr_supported_features = (
|
_attr_supported_features = (
|
||||||
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
|
||||||
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
|
|||||||
device=self._current_device,
|
device=self._current_device,
|
||||||
fw_data=fw_data,
|
fw_data=fw_data,
|
||||||
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
|
||||||
bootloader_reset_methods=self.bootloader_reset_methods,
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
progress_callback=self._update_progress,
|
progress_callback=self._update_progress,
|
||||||
domain=self._config_entry.domain,
|
domain=self._config_entry.domain,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
|
from collections.abc import AsyncIterator, Callable, Sequence
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
|
|||||||
|
|
||||||
|
|
||||||
async def probe_silabs_firmware_info(
|
async def probe_silabs_firmware_info(
|
||||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
device: str,
|
||||||
|
*,
|
||||||
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
) -> FirmwareInfo | None:
|
) -> FirmwareInfo | None:
|
||||||
"""Probe the running firmware on a SiLabs device."""
|
"""Probe the running firmware on a SiLabs device."""
|
||||||
flasher = Flasher(
|
flasher = Flasher(
|
||||||
device=device,
|
device=device,
|
||||||
**(
|
probe_methods=tuple(
|
||||||
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
|
(m.as_flasher_application_type(), baudrate)
|
||||||
if probe_methods
|
for m, baudrate in application_probe_methods
|
||||||
else {}
|
),
|
||||||
|
bootloader_reset=tuple(
|
||||||
|
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
|
|||||||
|
|
||||||
|
|
||||||
async def probe_silabs_firmware_type(
|
async def probe_silabs_firmware_type(
|
||||||
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
|
device: str,
|
||||||
|
*,
|
||||||
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
) -> ApplicationType | None:
|
) -> ApplicationType | None:
|
||||||
"""Probe the running firmware type on a SiLabs device."""
|
"""Probe the running firmware type on a SiLabs device."""
|
||||||
|
|
||||||
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
|
fw_info = await probe_silabs_firmware_info(
|
||||||
|
device,
|
||||||
|
bootloader_reset_methods=bootloader_reset_methods,
|
||||||
|
application_probe_methods=application_probe_methods,
|
||||||
|
)
|
||||||
if fw_info is None:
|
if fw_info is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
|
|||||||
device: str,
|
device: str,
|
||||||
fw_data: bytes,
|
fw_data: bytes,
|
||||||
expected_installed_firmware_type: ApplicationType,
|
expected_installed_firmware_type: ApplicationType,
|
||||||
bootloader_reset_methods: Sequence[ResetTarget] = (),
|
bootloader_reset_methods: Sequence[ResetTarget],
|
||||||
|
application_probe_methods: Sequence[tuple[ApplicationType, int]],
|
||||||
progress_callback: Callable[[int, int], None] | None = None,
|
progress_callback: Callable[[int, int], None] | None = None,
|
||||||
*,
|
*,
|
||||||
domain: str = DOMAIN,
|
domain: str = DOMAIN,
|
||||||
) -> FirmwareInfo:
|
) -> FirmwareInfo:
|
||||||
"""Flash firmware to the SiLabs device."""
|
"""Flash firmware to the SiLabs device."""
|
||||||
|
if not any(
|
||||||
|
method == expected_installed_firmware_type
|
||||||
|
for method, _ in application_probe_methods
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Expected installed firmware type {expected_installed_firmware_type!r}"
|
||||||
|
f" not in application probe methods {application_probe_methods!r}"
|
||||||
|
)
|
||||||
|
|
||||||
async with async_firmware_update_context(hass, device, domain):
|
async with async_firmware_update_context(hass, device, domain):
|
||||||
firmware_info = await guess_firmware_info(hass, device)
|
firmware_info = await guess_firmware_info(hass, device)
|
||||||
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
_LOGGER.debug("Identified firmware info: %s", firmware_info)
|
||||||
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
|
|||||||
|
|
||||||
flasher = Flasher(
|
flasher = Flasher(
|
||||||
device=device,
|
device=device,
|
||||||
probe_methods=(
|
probe_methods=tuple(
|
||||||
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
|
(m.as_flasher_application_type(), baudrate)
|
||||||
ApplicationType.EZSP.as_flasher_application_type(),
|
for m, baudrate in application_probe_methods
|
||||||
ApplicationType.SPINEL.as_flasher_application_type(),
|
|
||||||
ApplicationType.CPC.as_flasher_application_type(),
|
|
||||||
),
|
),
|
||||||
bootloader_reset=tuple(
|
bootloader_reset=tuple(
|
||||||
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
m.as_flasher_reset_target() for m in bootloader_reset_methods
|
||||||
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
|
|||||||
|
|
||||||
probed_firmware_info = await probe_silabs_firmware_info(
|
probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
device,
|
device,
|
||||||
probe_methods=(expected_installed_firmware_type,),
|
bootloader_reset_methods=bootloader_reset_methods,
|
||||||
|
# Only probe for the expected installed firmware type
|
||||||
|
application_probe_methods=[
|
||||||
|
(method, baudrate)
|
||||||
|
for method, baudrate in application_probe_methods
|
||||||
|
if method == expected_installed_firmware_type
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
if probed_firmware_info is None:
|
if probed_firmware_info is None:
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
|
ResetTarget,
|
||||||
)
|
)
|
||||||
from homeassistant.components.usb import (
|
from homeassistant.components.usb import (
|
||||||
usb_service_info_from_device,
|
usb_service_info_from_device,
|
||||||
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
|||||||
|
|
||||||
context: ConfigFlowContext
|
context: ConfigFlowContext
|
||||||
|
|
||||||
|
ZIGBEE_BAUDRATE = 115200
|
||||||
|
# There is no hardware bootloader trigger
|
||||||
|
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
# CPC baudrates can be removed once multiprotocol is removed
|
||||||
|
(ApplicationType.CPC, 115200),
|
||||||
|
(ApplicationType.CPC, 230400),
|
||||||
|
(ApplicationType.CPC, 460800),
|
||||||
|
(ApplicationType.ROUTER, 115200),
|
||||||
|
]
|
||||||
|
|
||||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||||
"""Shared translation placeholders."""
|
"""Shared translation placeholders."""
|
||||||
placeholders = {
|
placeholders = {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantSkyConnectConfigEntry
|
from . import HomeAssistantSkyConnectConfigEntry
|
||||||
|
from .config_flow import SkyConnectFirmwareMixin
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
FIRMWARE,
|
FIRMWARE,
|
||||||
@@ -151,8 +152,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""SkyConnect firmware update entity."""
|
"""SkyConnect firmware update entity."""
|
||||||
|
|
||||||
# The ZBT-1 does not have a hardware bootloader trigger
|
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
bootloader_reset_methods = []
|
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -82,7 +82,18 @@ else:
|
|||||||
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||||
"""Mixin for Home Assistant Yellow firmware methods."""
|
"""Mixin for Home Assistant Yellow firmware methods."""
|
||||||
|
|
||||||
|
ZIGBEE_BAUDRATE = 115200
|
||||||
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
|
||||||
|
APPLICATION_PROBE_METHODS = [
|
||||||
|
(ApplicationType.GECKO_BOOTLOADER, 115200),
|
||||||
|
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
|
||||||
|
(ApplicationType.SPINEL, 460800),
|
||||||
|
# CPC baudrates can be removed once multiprotocol is removed
|
||||||
|
(ApplicationType.CPC, 115200),
|
||||||
|
(ApplicationType.CPC, 230400),
|
||||||
|
(ApplicationType.CPC, 460800),
|
||||||
|
(ApplicationType.ROUTER, 115200),
|
||||||
|
]
|
||||||
|
|
||||||
async def async_step_install_zigbee_firmware(
|
async def async_step_install_zigbee_firmware(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
|
|||||||
assert self._device is not None
|
assert self._device is not None
|
||||||
|
|
||||||
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
|
||||||
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
|
self._probed_firmware_info = await probe_silabs_firmware_info(
|
||||||
|
self._device,
|
||||||
|
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
|
||||||
|
application_probe_methods=self.APPLICATION_PROBE_METHODS,
|
||||||
|
)
|
||||||
|
|
||||||
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
|
|||||||
from homeassistant.components.homeassistant_hardware.util import (
|
from homeassistant.components.homeassistant_hardware.util import (
|
||||||
ApplicationType,
|
ApplicationType,
|
||||||
FirmwareInfo,
|
FirmwareInfo,
|
||||||
ResetTarget,
|
|
||||||
)
|
)
|
||||||
from homeassistant.components.update import UpdateDeviceClass
|
from homeassistant.components.update import UpdateDeviceClass
|
||||||
from homeassistant.const import EntityCategory
|
from homeassistant.const import EntityCategory
|
||||||
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
|||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from . import HomeAssistantYellowConfigEntry
|
from . import HomeAssistantYellowConfigEntry
|
||||||
|
from .config_flow import YellowFirmwareMixin
|
||||||
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -150,7 +150,8 @@ async def async_setup_entry(
|
|||||||
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
|
||||||
"""Yellow firmware update entity."""
|
"""Yellow firmware update entity."""
|
||||||
|
|
||||||
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
|
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
|
||||||
|
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from aiopvapi.resources.model import PowerviewData
|
from aiopvapi.resources.model import PowerviewData
|
||||||
|
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||||
from aiopvapi.rooms import Rooms
|
from aiopvapi.rooms import Rooms
|
||||||
from aiopvapi.scenes import Scenes
|
from aiopvapi.scenes import Scenes
|
||||||
from aiopvapi.shades import Shades
|
from aiopvapi.shades import Shades
|
||||||
@@ -16,7 +17,6 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
|||||||
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
|
from .const import DOMAIN, HUB_EXCEPTIONS, MANUFACTURER
|
||||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||||
from .model import PowerviewConfigEntry, PowerviewEntryData
|
from .model import PowerviewConfigEntry, PowerviewEntryData
|
||||||
from .shade_data import PowerviewShadeData
|
|
||||||
from .util import async_connect_hub
|
from .util import async_connect_hub
|
||||||
|
|
||||||
PARALLEL_UPDATES = 1
|
PARALLEL_UPDATES = 1
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import logging
|
|||||||
|
|
||||||
from aiopvapi.helpers.aiorequest import PvApiMaintenance
|
from aiopvapi.helpers.aiorequest import PvApiMaintenance
|
||||||
from aiopvapi.hub import Hub
|
from aiopvapi.hub import Hub
|
||||||
|
from aiopvapi.resources.shade_data import PowerviewShadeData
|
||||||
from aiopvapi.shades import Shades
|
from aiopvapi.shades import Shades
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@@ -15,7 +16,6 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import HUB_EXCEPTIONS
|
from .const import HUB_EXCEPTIONS
|
||||||
from .shade_data import PowerviewShadeData
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -208,13 +208,13 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity):
|
|||||||
async def _async_execute_move(self, move: ShadePosition) -> None:
|
async def _async_execute_move(self, move: ShadePosition) -> None:
|
||||||
"""Execute a move that can affect multiple positions."""
|
"""Execute a move that can affect multiple positions."""
|
||||||
_LOGGER.debug("Move request %s: %s", self.name, move)
|
_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:
|
async with self.coordinator.radio_operation_lock:
|
||||||
response = await self._shade.move(move)
|
response = await self._shade.move(move)
|
||||||
_LOGGER.debug("Move response %s: %s", self.name, response)
|
_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:
|
async def _async_set_cover_position(self, target_hass_position: int) -> None:
|
||||||
"""Move the shade to a position."""
|
"""Move the shade to a position."""
|
||||||
target_hass_position = self._clamp_cover_limit(target_hass_position)
|
target_hass_position = self._clamp_cover_limit(target_hass_position)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from aiopvapi.resources.shade import BaseShade, ShadePosition
|
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 import device_registry as dr
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
@@ -11,7 +12,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
|||||||
from .const import DOMAIN, MANUFACTURER
|
from .const import DOMAIN, MANUFACTURER
|
||||||
from .coordinator import PowerviewShadeUpdateCoordinator
|
from .coordinator import PowerviewShadeUpdateCoordinator
|
||||||
from .model import PowerviewDeviceInfo
|
from .model import PowerviewDeviceInfo
|
||||||
from .shade_data import PowerviewShadeData
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,6 @@
|
|||||||
},
|
},
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["aiopvapi"],
|
"loggers": ["aiopvapi"],
|
||||||
"requirements": ["aiopvapi==3.2.1"],
|
"requirements": ["aiopvapi==3.3.0"],
|
||||||
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
|
"zeroconf": ["_powerview._tcp.local.", "_PowerView-G3._tcp.local."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
"""Shade data for the Hunter Douglas PowerView integration."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import fields
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from aiopvapi.resources.model import PowerviewData
|
|
||||||
from aiopvapi.resources.shade import BaseShade, ShadePosition
|
|
||||||
|
|
||||||
from .util import async_map_data_by_id
|
|
||||||
|
|
||||||
POSITION_FIELDS = [field for field in fields(ShadePosition) if field.name != "velocity"]
|
|
||||||
|
|
||||||
|
|
||||||
def copy_position_data(source: ShadePosition, target: ShadePosition) -> ShadePosition:
|
|
||||||
"""Copy position data from source to target for None values only."""
|
|
||||||
for field in POSITION_FIELDS:
|
|
||||||
if (value := getattr(source, field.name)) is not None:
|
|
||||||
setattr(target, field.name, value)
|
|
||||||
|
|
||||||
|
|
||||||
class PowerviewShadeData:
|
|
||||||
"""Coordinate shade data between multiple api calls."""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
"""Init the shade data."""
|
|
||||||
self._raw_data_by_id: dict[int, dict[str | int, Any]] = {}
|
|
||||||
self._shade_group_data_by_id: dict[int, BaseShade] = {}
|
|
||||||
self.positions: dict[int, ShadePosition] = {}
|
|
||||||
|
|
||||||
def get_raw_data(self, shade_id: int) -> dict[str | int, Any]:
|
|
||||||
"""Get data for the shade."""
|
|
||||||
return self._raw_data_by_id[shade_id]
|
|
||||||
|
|
||||||
def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]:
|
|
||||||
"""Get data for all shades."""
|
|
||||||
return self._raw_data_by_id
|
|
||||||
|
|
||||||
def get_shade(self, shade_id: int) -> BaseShade:
|
|
||||||
"""Get specific shade from the coordinator."""
|
|
||||||
return self._shade_group_data_by_id[shade_id]
|
|
||||||
|
|
||||||
def get_shade_position(self, shade_id: int) -> ShadePosition:
|
|
||||||
"""Get positions for a shade."""
|
|
||||||
if shade_id not in self.positions:
|
|
||||||
shade_position = ShadePosition()
|
|
||||||
# If we have the group data, use it to populate the initial position
|
|
||||||
if shade := self._shade_group_data_by_id.get(shade_id):
|
|
||||||
copy_position_data(shade.current_position, shade_position)
|
|
||||||
self.positions[shade_id] = shade_position
|
|
||||||
return self.positions[shade_id]
|
|
||||||
|
|
||||||
def update_from_group_data(self, shade_id: int) -> None:
|
|
||||||
"""Process an update from the group data."""
|
|
||||||
data = self._shade_group_data_by_id[shade_id]
|
|
||||||
copy_position_data(data.current_position, self.get_shade_position(data.id))
|
|
||||||
|
|
||||||
def store_group_data(self, shade_data: PowerviewData) -> None:
|
|
||||||
"""Store data from the all shades endpoint.
|
|
||||||
|
|
||||||
This does not update the shades or positions (self.positions)
|
|
||||||
as the data may be stale. update_from_group_data
|
|
||||||
with a shade_id will update a specific shade
|
|
||||||
from the group data.
|
|
||||||
"""
|
|
||||||
self._shade_group_data_by_id = shade_data.processed
|
|
||||||
self._raw_data_by_id = async_map_data_by_id(shade_data.raw)
|
|
||||||
|
|
||||||
def update_shade_position(self, shade_id: int, new_position: ShadePosition) -> None:
|
|
||||||
"""Update a single shades position."""
|
|
||||||
copy_position_data(new_position, self.get_shade_position(shade_id))
|
|
||||||
|
|
||||||
def update_shade_velocity(self, shade_id: int, shade_data: ShadePosition) -> None:
|
|
||||||
"""Update a single shades velocity."""
|
|
||||||
# the hub will always return a velocity of 0 on initial connect,
|
|
||||||
# separate definition to store consistent value in HA
|
|
||||||
# this value is purely driven from HA
|
|
||||||
if shade_data.velocity is not None:
|
|
||||||
self.get_shade_position(shade_id).velocity = shade_data.velocity
|
|
||||||
@@ -2,25 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Iterable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from aiopvapi.helpers.aiorequest import AioRequest
|
from aiopvapi.helpers.aiorequest import AioRequest
|
||||||
from aiopvapi.helpers.constants import ATTR_ID
|
|
||||||
from aiopvapi.hub import Hub
|
from aiopvapi.hub import Hub
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
from .model import PowerviewAPI, PowerviewDeviceInfo
|
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(
|
async def async_connect_hub(
|
||||||
hass: HomeAssistant, address: str, api_version: int | None = None
|
hass: HomeAssistant, address: str, api_version: int | None = None
|
||||||
) -> PowerviewAPI:
|
) -> PowerviewAPI:
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from typing import Any
|
|||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from hyperion import client
|
from hyperion import client
|
||||||
from hyperion.const import (
|
from hyperion.const import (
|
||||||
|
KEY_DATA,
|
||||||
KEY_IMAGE,
|
KEY_IMAGE,
|
||||||
KEY_IMAGE_STREAM,
|
KEY_IMAGE_STREAM,
|
||||||
KEY_LEDCOLORS,
|
KEY_LEDCOLORS,
|
||||||
@@ -155,7 +156,8 @@ class HyperionCamera(Camera):
|
|||||||
"""Update Hyperion components."""
|
"""Update Hyperion components."""
|
||||||
if not img:
|
if not img:
|
||||||
return
|
return
|
||||||
img_data = img.get(KEY_RESULT, {}).get(KEY_IMAGE)
|
# 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)
|
||||||
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
if not img_data or not img_data.startswith(IMAGE_STREAM_JPG_SENTINEL):
|
||||||
return
|
return
|
||||||
async with self._image_cond:
|
async with self._image_cond:
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
from pyituran import Vehicle
|
from pyituran import Vehicle
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
@@ -69,7 +68,7 @@ class IturanBinarySensor(IturanBaseEntity, BinarySensorEntity):
|
|||||||
super().__init__(coordinator, license_plate, description.key)
|
super().__init__(coordinator, license_plate, description.key)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def is_on(self) -> bool:
|
def is_on(self) -> bool:
|
||||||
"""Return true if the binary sensor is on."""
|
"""Return true if the binary sensor is on."""
|
||||||
return self.entity_description.value_fn(self.vehicle)
|
return self.entity_description.value_fn(self.vehicle)
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
|
|
||||||
from homeassistant.components.device_tracker import TrackerEntity
|
from homeassistant.components.device_tracker import TrackerEntity
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
@@ -40,12 +38,12 @@ class IturanDeviceTracker(IturanBaseEntity, TrackerEntity):
|
|||||||
"""Initialize the device tracker."""
|
"""Initialize the device tracker."""
|
||||||
super().__init__(coordinator, license_plate, "device_tracker")
|
super().__init__(coordinator, license_plate, "device_tracker")
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def latitude(self) -> float | None:
|
def latitude(self) -> float | None:
|
||||||
"""Return latitude value of the device."""
|
"""Return latitude value of the device."""
|
||||||
return self.vehicle.gps_coordinates[0]
|
return self.vehicle.gps_coordinates[0]
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def longitude(self) -> float | None:
|
def longitude(self) -> float | None:
|
||||||
"""Return longitude value of the device."""
|
"""Return longitude value of the device."""
|
||||||
return self.vehicle.gps_coordinates[1]
|
return self.vehicle.gps_coordinates[1]
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from collections.abc import Callable
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from propcache.api import cached_property
|
|
||||||
from pyituran import Vehicle
|
from pyituran import Vehicle
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
@@ -133,7 +132,7 @@ class IturanSensor(IturanBaseEntity, SensorEntity):
|
|||||||
super().__init__(coordinator, license_plate, description.key)
|
super().__init__(coordinator, license_plate, description.key)
|
||||||
self.entity_description = description
|
self.entity_description = description
|
||||||
|
|
||||||
@cached_property
|
@property
|
||||||
def native_value(self) -> StateType | datetime:
|
def native_value(self) -> StateType | datetime:
|
||||||
"""Return the state of the device."""
|
"""Return the state of the device."""
|
||||||
return self.entity_description.value_fn(self.vehicle)
|
return self.entity_description.value_fn(self.vehicle)
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
|
|||||||
await self.coordinator.device.update_firmware()
|
await self.coordinator.device.update_firmware()
|
||||||
while (
|
while (
|
||||||
update_progress := await self.coordinator.device.get_firmware()
|
update_progress := await self.coordinator.device.get_firmware()
|
||||||
).command_status is UpdateStatus.IN_PROGRESS:
|
).command_status is not UpdateStatus.UPDATED:
|
||||||
if counter >= MAX_UPDATE_WAIT:
|
if counter >= MAX_UPDATE_WAIT:
|
||||||
_raise_timeout_error()
|
_raise_timeout_error()
|
||||||
self._attr_update_percentage = update_progress.progress_percentage
|
self._attr_update_percentage = update_progress.progress_percentage
|
||||||
|
|||||||
@@ -94,28 +94,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"services": {
|
"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": {
|
"dyn_text": {
|
||||||
"description": "Sends dynamic text to LCN-GTxD displays.",
|
"description": "Sends dynamic text to LCN-GTxD displays.",
|
||||||
"fields": {
|
"fields": {
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["ical"],
|
"loggers": ["ical"],
|
||||||
"requirements": ["ical==11.0.0"]
|
"requirements": ["ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
"documentation": "https://www.home-assistant.io/integrations/local_todo",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["ical==11.0.0"]
|
"requirements": ["ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["lunatone-rest-api-client==0.5.3"]
|
"requirements": ["lunatone-rest-api-client==0.5.7"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,17 +353,13 @@ DISCOVERY_SCHEMAS = [
|
|||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
# DeviceFault or SupplyFault bit enabled
|
# DeviceFault or SupplyFault bit enabled
|
||||||
device_to_ha={
|
device_to_ha=lambda x: bool(
|
||||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault: True,
|
x
|
||||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault: True,
|
& (
|
||||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedLow: False,
|
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kDeviceFault
|
||||||
clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSpeedHigh: False,
|
| clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kSupplyFault
|
||||||
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,
|
entity_class=MatterBinarySensor,
|
||||||
required_attributes=(
|
required_attributes=(
|
||||||
@@ -377,9 +373,9 @@ DISCOVERY_SCHEMAS = [
|
|||||||
key="PumpStatusRunning",
|
key="PumpStatusRunning",
|
||||||
translation_key="pump_running",
|
translation_key="pump_running",
|
||||||
device_class=BinarySensorDeviceClass.RUNNING,
|
device_class=BinarySensorDeviceClass.RUNNING,
|
||||||
device_to_ha=lambda x: (
|
device_to_ha=lambda x: bool(
|
||||||
x
|
x
|
||||||
== clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
|
& clusters.PumpConfigurationAndControl.Bitmaps.PumpStatusBitmap.kRunning
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
@@ -395,8 +391,8 @@ DISCOVERY_SCHEMAS = [
|
|||||||
translation_key="dishwasher_alarm_inflow",
|
translation_key="dishwasher_alarm_inflow",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
device_to_ha=lambda x: (
|
device_to_ha=lambda x: bool(
|
||||||
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
|
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kInflowError
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
@@ -410,8 +406,8 @@ DISCOVERY_SCHEMAS = [
|
|||||||
translation_key="alarm_door",
|
translation_key="alarm_door",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
device_to_ha=lambda x: (
|
device_to_ha=lambda x: bool(
|
||||||
x == clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
|
x & clusters.DishwasherAlarm.Bitmaps.AlarmBitmap.kDoorError
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
@@ -425,9 +421,10 @@ DISCOVERY_SCHEMAS = [
|
|||||||
translation_key="valve_fault_general_fault",
|
translation_key="valve_fault_general_fault",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
device_to_ha=lambda x: (
|
# GeneralFault bit from ValveFault attribute
|
||||||
|
device_to_ha=lambda x: bool(
|
||||||
x
|
x
|
||||||
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
|
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kGeneralFault
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
@@ -443,9 +440,10 @@ DISCOVERY_SCHEMAS = [
|
|||||||
translation_key="valve_fault_blocked",
|
translation_key="valve_fault_blocked",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
device_to_ha=lambda x: (
|
# Blocked bit from ValveFault attribute
|
||||||
|
device_to_ha=lambda x: bool(
|
||||||
x
|
x
|
||||||
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
|
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kBlocked
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
@@ -461,9 +459,10 @@ DISCOVERY_SCHEMAS = [
|
|||||||
translation_key="valve_fault_leaking",
|
translation_key="valve_fault_leaking",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
device_to_ha=lambda x: (
|
# Leaking bit from ValveFault attribute
|
||||||
|
device_to_ha=lambda x: bool(
|
||||||
x
|
x
|
||||||
== clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
|
& clusters.ValveConfigurationAndControl.Bitmaps.ValveFaultBitmap.kLeaking
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
@@ -478,8 +477,8 @@ DISCOVERY_SCHEMAS = [
|
|||||||
translation_key="alarm_door",
|
translation_key="alarm_door",
|
||||||
device_class=BinarySensorDeviceClass.PROBLEM,
|
device_class=BinarySensorDeviceClass.PROBLEM,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
device_to_ha=lambda x: (
|
device_to_ha=lambda x: bool(
|
||||||
x == clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
|
x & clusters.RefrigeratorAlarm.Bitmaps.AlarmBitmap.kDoorOpen
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
entity_class=MatterBinarySensor,
|
entity_class=MatterBinarySensor,
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["aiomealie==1.0.1"]
|
"requirements": ["aiomealie==1.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1009,7 +1009,7 @@
|
|||||||
"cleaning_care_program": "Cleaning/care program",
|
"cleaning_care_program": "Cleaning/care program",
|
||||||
"maintenance_program": "Maintenance program",
|
"maintenance_program": "Maintenance program",
|
||||||
"normal_operation_mode": "Normal operation mode",
|
"normal_operation_mode": "Normal operation mode",
|
||||||
"own_program": "Own program"
|
"own_program": "Program"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"remaining_time": {
|
"remaining_time": {
|
||||||
@@ -1089,7 +1089,7 @@
|
|||||||
"message": "Invalid device targeted."
|
"message": "Invalid device targeted."
|
||||||
},
|
},
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
},
|
},
|
||||||
"set_program_error": {
|
"set_program_error": {
|
||||||
"message": "'Set program' action failed: {status} / {message}"
|
"message": "'Set program' action failed: {status} / {message}"
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/mill",
|
"documentation": "https://www.home-assistant.io/integrations/mill",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["mill", "mill_local"],
|
"loggers": ["mill", "mill_local"],
|
||||||
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
|
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,11 +61,13 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
async_add_entities([MobileAppBinarySensor(data, config_entry)])
|
||||||
|
|
||||||
|
config_entry.async_on_unload(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
hass,
|
hass,
|
||||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||||
handle_sensor_registration,
|
handle_sensor_registration,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity):
|
class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity):
|
||||||
|
|||||||
@@ -72,11 +72,13 @@ async def async_setup_entry(
|
|||||||
|
|
||||||
async_add_entities([MobileAppSensor(data, config_entry)])
|
async_add_entities([MobileAppSensor(data, config_entry)])
|
||||||
|
|
||||||
|
config_entry.async_on_unload(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
hass,
|
hass,
|
||||||
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
f"{DOMAIN}_{ENTITY_TYPE}_register",
|
||||||
handle_sensor_registration,
|
handle_sensor_registration,
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class MobileAppSensor(MobileAppEntity, RestoreSensor):
|
class MobileAppSensor(MobileAppEntity, RestoreSensor):
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ ABBREVIATIONS = {
|
|||||||
"fan_mode_stat_t": "fan_mode_state_topic",
|
"fan_mode_stat_t": "fan_mode_state_topic",
|
||||||
"frc_upd": "force_update",
|
"frc_upd": "force_update",
|
||||||
"g_tpl": "green_template",
|
"g_tpl": "green_template",
|
||||||
|
"grp": "group",
|
||||||
"hs_cmd_t": "hs_command_topic",
|
"hs_cmd_t": "hs_command_topic",
|
||||||
"hs_cmd_tpl": "hs_command_template",
|
"hs_cmd_tpl": "hs_command_template",
|
||||||
"hs_stat_t": "hs_state_topic",
|
"hs_stat_t": "hs_state_topic",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_COMMAND_TOPIC,
|
CONF_COMMAND_TOPIC,
|
||||||
CONF_ENCODING,
|
CONF_ENCODING,
|
||||||
|
CONF_GROUP,
|
||||||
CONF_QOS,
|
CONF_QOS,
|
||||||
CONF_RETAIN,
|
CONF_RETAIN,
|
||||||
CONF_STATE_TOPIC,
|
CONF_STATE_TOPIC,
|
||||||
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
|
|||||||
SCHEMA_BASE = {
|
SCHEMA_BASE = {
|
||||||
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
|
||||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
|
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
|
||||||
|
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
|
||||||
}
|
}
|
||||||
|
|
||||||
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)
|
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
|
|||||||
CONF_GET_POSITION_TEMPLATE = "position_template"
|
CONF_GET_POSITION_TEMPLATE = "position_template"
|
||||||
CONF_GET_POSITION_TOPIC = "position_topic"
|
CONF_GET_POSITION_TOPIC = "position_topic"
|
||||||
CONF_GREEN_TEMPLATE = "green_template"
|
CONF_GREEN_TEMPLATE = "green_template"
|
||||||
|
CONF_GROUP = "group"
|
||||||
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
|
||||||
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
|
||||||
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
CONF_HS_STATE_TOPIC = "hs_state_topic"
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ from .const import (
|
|||||||
CONF_ENABLED_BY_DEFAULT,
|
CONF_ENABLED_BY_DEFAULT,
|
||||||
CONF_ENCODING,
|
CONF_ENCODING,
|
||||||
CONF_ENTITY_PICTURE,
|
CONF_ENTITY_PICTURE,
|
||||||
|
CONF_GROUP,
|
||||||
CONF_HW_VERSION,
|
CONF_HW_VERSION,
|
||||||
CONF_IDENTIFIERS,
|
CONF_IDENTIFIERS,
|
||||||
CONF_JSON_ATTRS_TEMPLATE,
|
CONF_JSON_ATTRS_TEMPLATE,
|
||||||
@@ -136,6 +137,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
|
|||||||
"device_class",
|
"device_class",
|
||||||
"device_info",
|
"device_info",
|
||||||
"entity_category",
|
"entity_category",
|
||||||
|
"entity_id",
|
||||||
"entity_picture",
|
"entity_picture",
|
||||||
"entity_registry_enabled_default",
|
"entity_registry_enabled_default",
|
||||||
"extra_state_attributes",
|
"extra_state_attributes",
|
||||||
@@ -475,6 +477,8 @@ class MqttAttributesMixin(Entity):
|
|||||||
def __init__(self, config: ConfigType) -> None:
|
def __init__(self, config: ConfigType) -> None:
|
||||||
"""Initialize the JSON attributes mixin."""
|
"""Initialize the JSON attributes mixin."""
|
||||||
self._attributes_sub_state: dict[str, EntitySubscription] = {}
|
self._attributes_sub_state: dict[str, EntitySubscription] = {}
|
||||||
|
if CONF_GROUP in config:
|
||||||
|
self._attr_included_unique_ids = config[CONF_GROUP]
|
||||||
self._attributes_config = config
|
self._attributes_config = config
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
@@ -546,7 +550,7 @@ class MqttAttributesMixin(Entity):
|
|||||||
_LOGGER.warning("Erroneous JSON: %s", payload)
|
_LOGGER.warning("Erroneous JSON: %s", payload)
|
||||||
else:
|
else:
|
||||||
if isinstance(json_dict, dict):
|
if isinstance(json_dict, dict):
|
||||||
filtered_dict = {
|
filtered_dict: dict[str, Any] = {
|
||||||
k: v
|
k: v
|
||||||
for k, v in json_dict.items()
|
for k, v in json_dict.items()
|
||||||
if k not in MQTT_ATTRIBUTES_BLOCKED
|
if k not in MQTT_ATTRIBUTES_BLOCKED
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from music_assistant_client.exceptions import (
|
|||||||
from music_assistant_models.api import ServerInfoMessage
|
from music_assistant_models.api import ServerInfoMessage
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import SOURCE_IGNORE, ConfigFlow, ConfigFlowResult
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
from homeassistant.const import CONF_URL
|
from homeassistant.const import CONF_URL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import aiohttp_client
|
from homeassistant.helpers import aiohttp_client
|
||||||
@@ -21,21 +21,14 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
|||||||
|
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
|
|
||||||
DEFAULT_URL = "http://mass.local:8095"
|
|
||||||
DEFAULT_TITLE = "Music Assistant"
|
DEFAULT_TITLE = "Music Assistant"
|
||||||
|
DEFAULT_URL = "http://mass.local:8095"
|
||||||
|
|
||||||
|
|
||||||
def get_manual_schema(user_input: dict[str, Any]) -> vol.Schema:
|
STEP_USER_SCHEMA = vol.Schema({vol.Required(CONF_URL): str})
|
||||||
"""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."""
|
"""Validate the user input allows us to connect."""
|
||||||
async with MusicAssistantClient(
|
async with MusicAssistantClient(
|
||||||
url, aiohttp_client.async_get_clientsession(hass)
|
url, aiohttp_client.async_get_clientsession(hass)
|
||||||
@@ -52,25 +45,17 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
"""Set up flow instance."""
|
"""Set up flow instance."""
|
||||||
self.server_info: ServerInfoMessage | None = None
|
self.url: str | None = None
|
||||||
|
|
||||||
async def async_step_user(
|
async def async_step_user(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle a manual configuration."""
|
"""Handle a manual configuration."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
try:
|
try:
|
||||||
self.server_info = await get_server_info(
|
server_info = await _get_server_info(self.hass, user_input[CONF_URL])
|
||||||
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:
|
except CannotConnect:
|
||||||
errors["base"] = "cannot_connect"
|
errors["base"] = "cannot_connect"
|
||||||
except InvalidServerVersion:
|
except InvalidServerVersion:
|
||||||
@@ -79,68 +64,49 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
LOGGER.exception("Unexpected exception")
|
LOGGER.exception("Unexpected exception")
|
||||||
errors["base"] = "unknown"
|
errors["base"] = "unknown"
|
||||||
else:
|
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(
|
return self.async_create_entry(
|
||||||
title=DEFAULT_TITLE,
|
title=DEFAULT_TITLE,
|
||||||
data={
|
data={CONF_URL: user_input[CONF_URL]},
|
||||||
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(
|
return self.async_show_form(
|
||||||
step_id="user", data_schema=get_manual_schema(user_input), errors=errors
|
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(
|
async def async_step_zeroconf(
|
||||||
self, discovery_info: ZeroconfServiceInfo
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle a discovered Mass server.
|
"""Handle a zeroconf discovery for a Music Assistant 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:
|
try:
|
||||||
await get_server_info(self.hass, current_url)
|
server_info = ServerInfoMessage.from_dict(discovery_info.properties)
|
||||||
# Current URL is working, no need to update
|
except LookupError:
|
||||||
return self.async_abort(reason="already_configured")
|
return self.async_abort(reason="invalid_discovery_info")
|
||||||
except CannotConnect:
|
|
||||||
# Current URL is not working, update to the discovered URL
|
self.url = server_info.base_url
|
||||||
# and continue to discovery confirm
|
|
||||||
self.hass.config_entries.async_update_entry(
|
await self.async_set_unique_id(server_info.server_id)
|
||||||
existing_entry,
|
self._abort_if_unique_id_configured(updates={CONF_URL: self.url})
|
||||||
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:
|
try:
|
||||||
await get_server_info(self.hass, self.server_info.base_url)
|
await _get_server_info(self.hass, self.url)
|
||||||
except CannotConnect:
|
except CannotConnect:
|
||||||
return self.async_abort(reason="cannot_connect")
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
|
||||||
return await self.async_step_discovery_confirm()
|
return await self.async_step_discovery_confirm()
|
||||||
|
|
||||||
async def async_step_discovery_confirm(
|
async def async_step_discovery_confirm(
|
||||||
@@ -148,16 +114,16 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle user-confirmation of discovered server."""
|
"""Handle user-confirmation of discovered server."""
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
assert self.server_info is not None
|
assert self.url is not None
|
||||||
|
|
||||||
if user_input is not None:
|
if user_input is not None:
|
||||||
return self.async_create_entry(
|
return self.async_create_entry(
|
||||||
title=DEFAULT_TITLE,
|
title=DEFAULT_TITLE,
|
||||||
data={
|
data={CONF_URL: self.url},
|
||||||
CONF_URL: self.server_info.base_url,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._set_confirm_only()
|
self._set_confirm_only()
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="discovery_confirm",
|
step_id="discovery_confirm",
|
||||||
description_placeholders={"url": self.server_info.base_url},
|
description_placeholders={"url": self.url},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"domain": "music_assistant",
|
"domain": "music_assistant",
|
||||||
"name": "Music Assistant",
|
"name": "Music Assistant",
|
||||||
"after_dependencies": ["media_source", "media_player"],
|
"after_dependencies": ["media_source", "media_player"],
|
||||||
"codeowners": ["@music-assistant"],
|
"codeowners": ["@music-assistant", "@arturpragacz"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
|
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
"message": "Error while loading the integration."
|
"message": "Error while loading the integration."
|
||||||
},
|
},
|
||||||
"implementation_unavailable": {
|
"implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation is not available, will retry."
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
},
|
},
|
||||||
"incorrect_oauth2_scope": {
|
"incorrect_oauth2_scope": {
|
||||||
"message": "Stored permissions are invalid. Please login again to update permissions."
|
"message": "Stored permissions are invalid. Please login again to update permissions."
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
from homeassistant.const import CONF_WEBHOOK_ID, EVENT_HOMEASSISTANT_STOP
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import aiohttp_client, config_validation as cv
|
||||||
aiohttp_client,
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||||
config_entry_oauth2_flow,
|
ImplementationUnavailableError,
|
||||||
config_validation as cv,
|
OAuth2Session,
|
||||||
|
async_get_config_entry_implementation,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.device_registry import DeviceEntry
|
from homeassistant.helpers.device_registry import DeviceEntry
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||||
@@ -73,17 +74,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Set up Netatmo from a config entry."""
|
"""Set up Netatmo from a config entry."""
|
||||||
implementation = (
|
try:
|
||||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||||
hass, entry
|
except ImplementationUnavailableError as err:
|
||||||
)
|
raise ConfigEntryNotReady(
|
||||||
)
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="oauth2_implementation_unavailable",
|
||||||
|
) from err
|
||||||
|
|
||||||
# Set unique id if non was set (migration)
|
# Set unique id if non was set (migration)
|
||||||
if not entry.unique_id:
|
if not entry.unique_id:
|
||||||
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
|
||||||
|
|
||||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
session = OAuth2Session(hass, entry, implementation)
|
||||||
try:
|
try:
|
||||||
await session.async_ensure_token_valid()
|
await session.async_ensure_token_valid()
|
||||||
except aiohttp.ClientResponseError as ex:
|
except aiohttp.ClientResponseError as ex:
|
||||||
|
|||||||
@@ -143,6 +143,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"oauth2_implementation_unavailable": {
|
||||||
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
|
}
|
||||||
|
},
|
||||||
"options": {
|
"options": {
|
||||||
"step": {
|
"step": {
|
||||||
"public_weather": {
|
"public_weather": {
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ from homeassistant.helpers import entity_registry as er
|
|||||||
|
|
||||||
from .const import _LOGGER
|
from .const import _LOGGER
|
||||||
|
|
||||||
PLATFORMS: list[Platform] = [Platform.COVER, Platform.LIGHT, Platform.SCENE]
|
PLATFORMS: list[Platform] = [
|
||||||
|
Platform.CLIMATE,
|
||||||
|
Platform.COVER,
|
||||||
|
Platform.LIGHT,
|
||||||
|
Platform.SCENE,
|
||||||
|
]
|
||||||
|
|
||||||
type NikoHomeControlConfigEntry = ConfigEntry[NHCController]
|
type NikoHomeControlConfigEntry = ConfigEntry[NHCController]
|
||||||
|
|
||||||
|
|||||||
100
homeassistant/components/niko_home_control/climate.py
Normal file
100
homeassistant/components/niko_home_control/climate.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""Support for Niko Home Control thermostats."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nhc.const import THERMOSTAT_MODES, THERMOSTAT_MODES_REVERSE
|
||||||
|
from nhc.thermostat import NHCThermostat
|
||||||
|
|
||||||
|
from homeassistant.components.climate import (
|
||||||
|
PRESET_ECO,
|
||||||
|
ClimateEntity,
|
||||||
|
ClimateEntityFeature,
|
||||||
|
HVACMode,
|
||||||
|
)
|
||||||
|
from homeassistant.components.sensor import UnitOfTemperature
|
||||||
|
from homeassistant.const import ATTR_TEMPERATURE
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
|
from . import NikoHomeControlConfigEntry
|
||||||
|
from .const import (
|
||||||
|
NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP,
|
||||||
|
NikoHomeControlThermostatModes,
|
||||||
|
)
|
||||||
|
from .entity import NikoHomeControlEntity
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
entry: NikoHomeControlConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Niko Home Control thermostat entry."""
|
||||||
|
controller = entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
NikoHomeControlClimate(thermostat, controller, entry.entry_id)
|
||||||
|
for thermostat in controller.thermostats.values()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NikoHomeControlClimate(NikoHomeControlEntity, ClimateEntity):
|
||||||
|
"""Representation of a Niko Home Control thermostat."""
|
||||||
|
|
||||||
|
_attr_supported_features: ClimateEntityFeature = (
|
||||||
|
ClimateEntityFeature.PRESET_MODE
|
||||||
|
| ClimateEntityFeature.TARGET_TEMPERATURE
|
||||||
|
| ClimateEntityFeature.TURN_OFF
|
||||||
|
)
|
||||||
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
_attr_name = None
|
||||||
|
_action: NHCThermostat
|
||||||
|
|
||||||
|
_attr_translation_key = "nhc_thermostat"
|
||||||
|
|
||||||
|
_attr_hvac_modes = [HVACMode.OFF, HVACMode.COOL, HVACMode.AUTO]
|
||||||
|
|
||||||
|
_attr_preset_modes = [
|
||||||
|
"day",
|
||||||
|
"night",
|
||||||
|
PRESET_ECO,
|
||||||
|
"prog1",
|
||||||
|
"prog2",
|
||||||
|
"prog3",
|
||||||
|
]
|
||||||
|
|
||||||
|
def _get_niko_mode(self, mode: str) -> int:
|
||||||
|
"""Return the Niko mode."""
|
||||||
|
return THERMOSTAT_MODES_REVERSE.get(mode, NikoHomeControlThermostatModes.OFF)
|
||||||
|
|
||||||
|
async def async_set_temperature(self, **kwargs: Any) -> None:
|
||||||
|
"""Set new target temperature."""
|
||||||
|
if ATTR_TEMPERATURE in kwargs:
|
||||||
|
await self._action.set_temperature(kwargs.get(ATTR_TEMPERATURE))
|
||||||
|
|
||||||
|
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||||
|
"""Set new preset mode."""
|
||||||
|
await self._action.set_mode(self._get_niko_mode(preset_mode))
|
||||||
|
|
||||||
|
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
|
||||||
|
"""Set new target hvac mode."""
|
||||||
|
await self._action.set_mode(NIKO_HOME_CONTROL_THERMOSTAT_MODES_MAP[hvac_mode])
|
||||||
|
|
||||||
|
async def async_turn_off(self) -> None:
|
||||||
|
"""Turn thermostat off."""
|
||||||
|
await self._action.set_mode(NikoHomeControlThermostatModes.OFF)
|
||||||
|
|
||||||
|
def update_state(self) -> None:
|
||||||
|
"""Update the state of the entity."""
|
||||||
|
if self._action.state == NikoHomeControlThermostatModes.OFF:
|
||||||
|
self._attr_hvac_mode = HVACMode.OFF
|
||||||
|
self._attr_preset_mode = None
|
||||||
|
elif self._action.state == NikoHomeControlThermostatModes.COOL:
|
||||||
|
self._attr_hvac_mode = HVACMode.COOL
|
||||||
|
self._attr_preset_mode = None
|
||||||
|
else:
|
||||||
|
self._attr_hvac_mode = HVACMode.AUTO
|
||||||
|
self._attr_preset_mode = THERMOSTAT_MODES[self._action.state]
|
||||||
|
|
||||||
|
self._attr_target_temperature = self._action.setpoint
|
||||||
|
self._attr_current_temperature = self._action.measured
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
"""Constants for niko_home_control integration."""
|
"""Constants for niko_home_control integration."""
|
||||||
|
|
||||||
|
from enum import IntEnum
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from homeassistant.components.climate import HVACMode
|
||||||
|
|
||||||
DOMAIN = "niko_home_control"
|
DOMAIN = "niko_home_control"
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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
|
||||||
|
|||||||
20
homeassistant/components/niko_home_control/icons.json
Normal file
20
homeassistant/components/niko_home_control/icons.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"climate": {
|
||||||
|
"nhc_thermostat": {
|
||||||
|
"state_attributes": {
|
||||||
|
"preset_mode": {
|
||||||
|
"default": "mdi:calendar-clock",
|
||||||
|
"state": {
|
||||||
|
"day": "mdi:weather-sunny",
|
||||||
|
"night": "mdi:weather-night",
|
||||||
|
"prog1": "mdi:numeric-1",
|
||||||
|
"prog2": "mdi:numeric-2",
|
||||||
|
"prog3": "mdi:numeric-3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,5 +26,23 @@
|
|||||||
"description": "Set up your Niko Home Control instance."
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||||
},
|
},
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
},
|
},
|
||||||
"update_failed": {
|
"update_failed": {
|
||||||
"message": "Failed to update drive state"
|
"message": "Failed to update drive state"
|
||||||
|
|||||||
@@ -15,5 +15,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
"documentation": "https://www.home-assistant.io/integrations/palazzetti",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"requirements": ["pypalazzetti==0.1.19"]
|
"requirements": ["pypalazzetti==0.1.20"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ class PlaystationNetworkFriendDataCoordinator(
|
|||||||
account_id=self.user.account_id,
|
account_id=self.user.account_id,
|
||||||
presence=self.user.get_presence(),
|
presence=self.user.get_presence(),
|
||||||
profile=self.profile,
|
profile=self.profile,
|
||||||
|
trophy_summary=self.user.trophy_summary(),
|
||||||
)
|
)
|
||||||
except PSNAWPForbiddenError as error:
|
except PSNAWPForbiddenError as error:
|
||||||
raise UpdateFailed(
|
raise UpdateFailed(
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class PlaystationNetworkSensor(StrEnum):
|
|||||||
NOW_PLAYING = "now_playing"
|
NOW_PLAYING = "now_playing"
|
||||||
|
|
||||||
|
|
||||||
SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
|
SENSOR_DESCRIPTIONS: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
|
||||||
PlaystationNetworkSensorEntityDescription(
|
PlaystationNetworkSensorEntityDescription(
|
||||||
key=PlaystationNetworkSensor.TROPHY_LEVEL,
|
key=PlaystationNetworkSensor.TROPHY_LEVEL,
|
||||||
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
|
translation_key=PlaystationNetworkSensor.TROPHY_LEVEL,
|
||||||
@@ -106,8 +106,6 @@ SENSOR_DESCRIPTIONS_TROPHY: tuple[PlaystationNetworkSensorEntityDescription, ...
|
|||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
|
||||||
SENSOR_DESCRIPTIONS_USER: tuple[PlaystationNetworkSensorEntityDescription, ...] = (
|
|
||||||
PlaystationNetworkSensorEntityDescription(
|
PlaystationNetworkSensorEntityDescription(
|
||||||
key=PlaystationNetworkSensor.ONLINE_ID,
|
key=PlaystationNetworkSensor.ONLINE_ID,
|
||||||
translation_key=PlaystationNetworkSensor.ONLINE_ID,
|
translation_key=PlaystationNetworkSensor.ONLINE_ID,
|
||||||
@@ -152,7 +150,7 @@ async def async_setup_entry(
|
|||||||
coordinator = config_entry.runtime_data.user_data
|
coordinator = config_entry.runtime_data.user_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
PlaystationNetworkSensorEntity(coordinator, description)
|
PlaystationNetworkSensorEntity(coordinator, description)
|
||||||
for description in SENSOR_DESCRIPTIONS_TROPHY + SENSOR_DESCRIPTIONS_USER
|
for description in SENSOR_DESCRIPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
for (
|
for (
|
||||||
@@ -166,7 +164,7 @@ async def async_setup_entry(
|
|||||||
description,
|
description,
|
||||||
config_entry.subentries[subentry_id],
|
config_entry.subentries[subentry_id],
|
||||||
)
|
)
|
||||||
for description in SENSOR_DESCRIPTIONS_USER
|
for description in SENSOR_DESCRIPTIONS
|
||||||
],
|
],
|
||||||
config_subentry_id=subentry_id,
|
config_subentry_id=subentry_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,12 +57,14 @@ type SelectType = Literal[
|
|||||||
"select_gateway_mode",
|
"select_gateway_mode",
|
||||||
"select_regulation_mode",
|
"select_regulation_mode",
|
||||||
"select_schedule",
|
"select_schedule",
|
||||||
|
"select_zone_profile",
|
||||||
]
|
]
|
||||||
type SelectOptionsType = Literal[
|
type SelectOptionsType = Literal[
|
||||||
|
"available_schedules",
|
||||||
"dhw_modes",
|
"dhw_modes",
|
||||||
"gateway_modes",
|
"gateway_modes",
|
||||||
"regulation_modes",
|
"regulation_modes",
|
||||||
"available_schedules",
|
"zone_profiles",
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default directives
|
# Default directives
|
||||||
@@ -82,3 +84,10 @@ MASTER_THERMOSTATS: Final[list[str]] = [
|
|||||||
"zone_thermometer",
|
"zone_thermometer",
|
||||||
"zone_thermostat",
|
"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",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["plugwise"],
|
"loggers": ["plugwise"],
|
||||||
"quality_scale": "platinum",
|
"quality_scale": "platinum",
|
||||||
"requirements": ["plugwise==1.9.0"],
|
"requirements": ["plugwise==1.10.0"],
|
||||||
"zeroconf": ["_plugwise._tcp.local."]
|
"zeroconf": ["_plugwise._tcp.local."]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ from homeassistant.const import STATE_ON, EntityCategory
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import SelectOptionsType, SelectType
|
from .const import (
|
||||||
|
SELECT_DHW_MODE,
|
||||||
|
SELECT_GATEWAY_MODE,
|
||||||
|
SELECT_REGULATION_MODE,
|
||||||
|
SELECT_SCHEDULE,
|
||||||
|
SELECT_ZONE_PROFILE,
|
||||||
|
SelectOptionsType,
|
||||||
|
SelectType,
|
||||||
|
)
|
||||||
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
|
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
|
||||||
from .entity import PlugwiseEntity
|
from .entity import PlugwiseEntity
|
||||||
from .util import plugwise_command
|
from .util import plugwise_command
|
||||||
@@ -27,28 +35,34 @@ class PlugwiseSelectEntityDescription(SelectEntityDescription):
|
|||||||
|
|
||||||
SELECT_TYPES = (
|
SELECT_TYPES = (
|
||||||
PlugwiseSelectEntityDescription(
|
PlugwiseSelectEntityDescription(
|
||||||
key="select_schedule",
|
key=SELECT_SCHEDULE,
|
||||||
translation_key="select_schedule",
|
translation_key=SELECT_SCHEDULE,
|
||||||
options_key="available_schedules",
|
options_key="available_schedules",
|
||||||
),
|
),
|
||||||
PlugwiseSelectEntityDescription(
|
PlugwiseSelectEntityDescription(
|
||||||
key="select_regulation_mode",
|
key=SELECT_REGULATION_MODE,
|
||||||
translation_key="regulation_mode",
|
translation_key=SELECT_REGULATION_MODE,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
options_key="regulation_modes",
|
options_key="regulation_modes",
|
||||||
),
|
),
|
||||||
PlugwiseSelectEntityDescription(
|
PlugwiseSelectEntityDescription(
|
||||||
key="select_dhw_mode",
|
key=SELECT_DHW_MODE,
|
||||||
translation_key="dhw_mode",
|
translation_key=SELECT_DHW_MODE,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
options_key="dhw_modes",
|
options_key="dhw_modes",
|
||||||
),
|
),
|
||||||
PlugwiseSelectEntityDescription(
|
PlugwiseSelectEntityDescription(
|
||||||
key="select_gateway_mode",
|
key=SELECT_GATEWAY_MODE,
|
||||||
translation_key="gateway_mode",
|
translation_key=SELECT_GATEWAY_MODE,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
options_key="gateway_modes",
|
options_key="gateway_modes",
|
||||||
),
|
),
|
||||||
|
PlugwiseSelectEntityDescription(
|
||||||
|
key=SELECT_ZONE_PROFILE,
|
||||||
|
translation_key=SELECT_ZONE_PROFILE,
|
||||||
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
options_key="zone_profiles",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"dhw_mode": {
|
"select_dhw_mode": {
|
||||||
"name": "DHW mode",
|
"name": "DHW mode",
|
||||||
"state": {
|
"state": {
|
||||||
"auto": "[%key:common::state::auto%]",
|
"auto": "[%key:common::state::auto%]",
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
"off": "[%key:common::state::off%]"
|
"off": "[%key:common::state::off%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"gateway_mode": {
|
"select_gateway_mode": {
|
||||||
"name": "Gateway mode",
|
"name": "Gateway mode",
|
||||||
"state": {
|
"state": {
|
||||||
"away": "Pause",
|
"away": "Pause",
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"vacation": "Vacation"
|
"vacation": "Vacation"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"regulation_mode": {
|
"select_regulation_mode": {
|
||||||
"name": "Regulation mode",
|
"name": "Regulation mode",
|
||||||
"state": {
|
"state": {
|
||||||
"bleeding_cold": "Bleeding cold",
|
"bleeding_cold": "Bleeding cold",
|
||||||
@@ -141,6 +141,14 @@
|
|||||||
"state": {
|
"state": {
|
||||||
"off": "[%key:common::state::off%]"
|
"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": {
|
"sensor": {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
|
|||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
|
||||||
from ..const import SupportedDialect
|
from ..const import SupportedDialect
|
||||||
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
|
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
|
||||||
from ..util import session_scope
|
from ..util import session_scope
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
|
|||||||
or dialect_kwargs.get("mariadb_collate")
|
or dialect_kwargs.get("mariadb_collate")
|
||||||
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
|
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
|
||||||
)
|
)
|
||||||
if collate and collate != "utf8mb4_unicode_ci":
|
if collate and collate != MYSQL_COLLATE:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Database %s collation is not utf8mb4_unicode_ci",
|
"Database %s collation is not %s",
|
||||||
table,
|
table,
|
||||||
|
MYSQL_COLLATE,
|
||||||
)
|
)
|
||||||
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
|
schema_errors.add(f"{table}.{MYSQL_COLLATE}")
|
||||||
return schema_errors
|
return schema_errors
|
||||||
|
|
||||||
|
|
||||||
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
|
|||||||
table_name = table_object.__tablename__
|
table_name = table_object.__tablename__
|
||||||
if (
|
if (
|
||||||
f"{table_name}.4-byte UTF-8" in schema_errors
|
f"{table_name}.4-byte UTF-8" in schema_errors
|
||||||
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
|
or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
|
||||||
):
|
):
|
||||||
from ..migration import ( # noqa: PLC0415
|
from ..migration import ( # noqa: PLC0415
|
||||||
_correct_table_character_set_and_collation,
|
_correct_table_character_set_and_collation,
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ def validate_db_schema(instance: Recorder) -> set[str]:
|
|||||||
schema_errors |= validate_table_schema_supports_utf8(
|
schema_errors |= validate_table_schema_supports_utf8(
|
||||||
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
|
instance, StatisticsMeta, (StatisticsMeta.statistic_id,)
|
||||||
)
|
)
|
||||||
|
schema_errors |= validate_table_schema_has_correct_collation(
|
||||||
|
instance, StatisticsMeta
|
||||||
|
)
|
||||||
for table in (Statistics, StatisticsShortTerm):
|
for table in (Statistics, StatisticsShortTerm):
|
||||||
schema_errors |= validate_db_schema_precision(instance, table)
|
schema_errors |= validate_db_schema_precision(instance, table)
|
||||||
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
schema_errors |= validate_table_schema_has_correct_collation(instance, table)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ CONTEXT_ID_AS_BINARY_SCHEMA_VERSION = 36
|
|||||||
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
|
EVENT_TYPE_IDS_SCHEMA_VERSION = 37
|
||||||
STATES_META_SCHEMA_VERSION = 38
|
STATES_META_SCHEMA_VERSION = 38
|
||||||
CIRCULAR_MEAN_SCHEMA_VERSION = 49
|
CIRCULAR_MEAN_SCHEMA_VERSION = 49
|
||||||
UNIT_CLASS_SCHEMA_VERSION = 51
|
UNIT_CLASS_SCHEMA_VERSION = 52
|
||||||
|
|
||||||
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
||||||
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
|
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
|
|||||||
"""Base class for tables, used for schema migration."""
|
"""Base class for tables, used for schema migration."""
|
||||||
|
|
||||||
|
|
||||||
SCHEMA_VERSION = 51
|
SCHEMA_VERSION = 53
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
|
|||||||
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
|
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
|
||||||
CONTEXT_ID_BIN_MAX_LENGTH = 16
|
CONTEXT_ID_BIN_MAX_LENGTH = 16
|
||||||
|
|
||||||
MYSQL_COLLATE = "utf8mb4_unicode_ci"
|
MYSQL_COLLATE = "utf8mb4_bin"
|
||||||
MYSQL_DEFAULT_CHARSET = "utf8mb4"
|
MYSQL_DEFAULT_CHARSET = "utf8mb4"
|
||||||
MYSQL_ENGINE = "InnoDB"
|
MYSQL_ENGINE = "InnoDB"
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,15 @@ from typing import TYPE_CHECKING, Any, TypedDict, cast, final
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from sqlalchemy import ForeignKeyConstraint, MetaData, Table, func, text, update
|
from sqlalchemy import (
|
||||||
|
ForeignKeyConstraint,
|
||||||
|
MetaData,
|
||||||
|
Table,
|
||||||
|
cast as cast_,
|
||||||
|
func,
|
||||||
|
text,
|
||||||
|
update,
|
||||||
|
)
|
||||||
from sqlalchemy.engine import CursorResult, Engine
|
from sqlalchemy.engine import CursorResult, Engine
|
||||||
from sqlalchemy.exc import (
|
from sqlalchemy.exc import (
|
||||||
DatabaseError,
|
DatabaseError,
|
||||||
@@ -26,8 +34,9 @@ from sqlalchemy.exc import (
|
|||||||
from sqlalchemy.orm import DeclarativeBase
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
from sqlalchemy.orm.session import Session
|
from sqlalchemy.orm.session import Session
|
||||||
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
|
from sqlalchemy.schema import AddConstraint, CreateTable, DropConstraint
|
||||||
from sqlalchemy.sql.expression import true
|
from sqlalchemy.sql.expression import and_, true
|
||||||
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
from sqlalchemy.sql.lambdas import StatementLambdaElement
|
||||||
|
from sqlalchemy.types import BINARY
|
||||||
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.util.enum import try_parse_enum
|
from homeassistant.util.enum import try_parse_enum
|
||||||
@@ -1352,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
|
|||||||
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
|
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
|
||||||
def _apply_update(self) -> None:
|
def _apply_update(self) -> None:
|
||||||
"""Version specific update method."""
|
"""Version specific update method."""
|
||||||
# Try to change the character set of the statistic_meta table
|
# Try to change the character set of events, states and statistics_meta tables
|
||||||
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||||
for table in ("events", "states", "statistics_meta"):
|
for table in ("events", "states", "statistics_meta"):
|
||||||
_correct_table_character_set_and_collation(table, self.session_maker)
|
_correct_table_character_set_and_collation(table, self.session_maker)
|
||||||
@@ -2044,17 +2053,94 @@ class _SchemaVersion50Migrator(_SchemaVersionMigrator, target_version=50):
|
|||||||
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
|
class _SchemaVersion51Migrator(_SchemaVersionMigrator, target_version=51):
|
||||||
def _apply_update(self) -> None:
|
def _apply_update(self) -> None:
|
||||||
"""Version specific update method."""
|
"""Version specific update method."""
|
||||||
# Add unit class column to StatisticsMeta
|
# Replaced with version 52 which corrects issues with MySQL string comparisons.
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
|
||||||
|
def _apply_update(self) -> None:
|
||||||
|
"""Version specific update method."""
|
||||||
|
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||||
|
self._apply_update_mysql()
|
||||||
|
else:
|
||||||
|
self._apply_update_postgresql_sqlite()
|
||||||
|
|
||||||
|
def _apply_update_mysql(self) -> None:
|
||||||
|
"""Version specific update method for mysql."""
|
||||||
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
||||||
with session_scope(session=self.session_maker()) as session:
|
with session_scope(session=self.session_maker()) as session:
|
||||||
connection = session.connection()
|
connection = session.connection()
|
||||||
for conv in _PRIMARY_UNIT_CONVERTERS:
|
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(
|
connection.execute(
|
||||||
update(StatisticsMeta)
|
update(StatisticsMeta)
|
||||||
.where(StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS))
|
.where(
|
||||||
|
and_(
|
||||||
|
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
|
||||||
|
cast_(StatisticsMeta.unit_of_measurement, BINARY).not_in(
|
||||||
|
case_sensitive_units
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(unit_class=None)
|
||||||
|
)
|
||||||
|
# Do an explicitly case sensitive match (actually binary) to set the
|
||||||
|
# correct unit_class. This is needed because we use the case sensitive
|
||||||
|
# utf8mb4_unicode_ci collation.
|
||||||
|
connection.execute(
|
||||||
|
update(StatisticsMeta)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
cast_(StatisticsMeta.unit_of_measurement, BINARY).in_(
|
||||||
|
case_sensitive_units
|
||||||
|
),
|
||||||
|
StatisticsMeta.unit_class.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
.values(unit_class=conv.UNIT_CLASS)
|
.values(unit_class=conv.UNIT_CLASS)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_update_postgresql_sqlite(self) -> None:
|
||||||
|
"""Version specific update method for postgresql and sqlite."""
|
||||||
|
_add_columns(self.session_maker, "statistics_meta", ["unit_class VARCHAR(255)"])
|
||||||
|
with session_scope(session=self.session_maker()) as session:
|
||||||
|
connection = session.connection()
|
||||||
|
for conv in _PRIMARY_UNIT_CONVERTERS:
|
||||||
|
# Set the correct unit_class. Unlike MySQL, Postgres and SQLite
|
||||||
|
# have case sensitive string comparisons by default, so we
|
||||||
|
# can directly match on the valid units.
|
||||||
|
connection.execute(
|
||||||
|
update(StatisticsMeta)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
StatisticsMeta.unit_of_measurement.in_(conv.VALID_UNITS),
|
||||||
|
StatisticsMeta.unit_class.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.values(unit_class=conv.UNIT_CLASS)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
|
||||||
|
def _apply_update(self) -> None:
|
||||||
|
"""Version specific update method."""
|
||||||
|
# Try to change the character set of events, states and statistics_meta tables
|
||||||
|
if self.engine.dialect.name == SupportedDialect.MYSQL:
|
||||||
|
for table in (
|
||||||
|
"events",
|
||||||
|
"event_data",
|
||||||
|
"states",
|
||||||
|
"state_attributes",
|
||||||
|
"statistics",
|
||||||
|
"statistics_meta",
|
||||||
|
"statistics_short_term",
|
||||||
|
):
|
||||||
|
_correct_table_character_set_and_collation(table, self.session_maker)
|
||||||
|
|
||||||
|
|
||||||
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
|
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
@@ -2098,8 +2184,10 @@ def _correct_table_character_set_and_collation(
|
|||||||
"""Correct issues detected by validate_db_schema."""
|
"""Correct issues detected by validate_db_schema."""
|
||||||
# Attempt to convert the table to utf8mb4
|
# Attempt to convert the table to utf8mb4
|
||||||
_LOGGER.warning(
|
_LOGGER.warning(
|
||||||
"Updating character set and collation of table %s to utf8mb4. %s",
|
"Updating table %s to character set %s and collation %s. %s",
|
||||||
table,
|
table,
|
||||||
|
MYSQL_DEFAULT_CHARSET,
|
||||||
|
MYSQL_COLLATE,
|
||||||
MIGRATION_NOTE_MINUTES,
|
MIGRATION_NOTE_MINUTES,
|
||||||
)
|
)
|
||||||
with (
|
with (
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ CACHE_SIZE = 8192
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
QUERY_STATISTIC_META = (
|
QUERY_STATISTICS_META = (
|
||||||
StatisticsMeta.id,
|
StatisticsMeta.id,
|
||||||
StatisticsMeta.statistic_id,
|
StatisticsMeta.statistic_id,
|
||||||
StatisticsMeta.source,
|
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.
|
Depending on the schema version, either mean_type (added in version 49) or has_mean column is used.
|
||||||
"""
|
"""
|
||||||
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTIC_META)
|
columns: list[InstrumentedAttribute[Any]] = list(QUERY_STATISTICS_META)
|
||||||
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
|
if schema_version >= CIRCULAR_MEAN_SCHEMA_VERSION:
|
||||||
columns.append(StatisticsMeta.mean_type)
|
columns.append(StatisticsMeta.mean_type)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["ical"],
|
"loggers": ["ical"],
|
||||||
"quality_scale": "silver",
|
"quality_scale": "silver",
|
||||||
"requirements": ["ical==11.0.0"]
|
"requirements": ["ical==11.1.0"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections import OrderedDict
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from satel_integra.satel_integra import AlarmState
|
from satel_integra.satel_integra import AlarmState, AsyncSatel
|
||||||
|
|
||||||
from homeassistant.components.alarm_control_panel import (
|
from homeassistant.components.alarm_control_panel import (
|
||||||
AlarmControlPanelEntity,
|
AlarmControlPanelEntity,
|
||||||
@@ -16,17 +15,31 @@ from homeassistant.components.alarm_control_panel import (
|
|||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
CONF_ARM_HOME_MODE,
|
CONF_ARM_HOME_MODE,
|
||||||
CONF_PARTITION_NUMBER,
|
CONF_PARTITION_NUMBER,
|
||||||
|
DOMAIN,
|
||||||
SIGNAL_PANEL_MESSAGE,
|
SIGNAL_PANEL_MESSAGE,
|
||||||
SUBENTRY_TYPE_PARTITION,
|
SUBENTRY_TYPE_PARTITION,
|
||||||
SatelConfigEntry,
|
SatelConfigEntry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ALARM_STATE_MAP = {
|
||||||
|
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
|
||||||
|
AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED,
|
||||||
|
AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING,
|
||||||
|
AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME,
|
||||||
|
AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME,
|
||||||
|
AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME,
|
||||||
|
AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY,
|
||||||
|
AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING,
|
||||||
|
AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING,
|
||||||
|
}
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -45,9 +58,9 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for subentry in partition_subentries:
|
for subentry in partition_subentries:
|
||||||
partition_num = subentry.data[CONF_PARTITION_NUMBER]
|
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
|
||||||
zone_name = subentry.data[CONF_NAME]
|
zone_name: str = subentry.data[CONF_NAME]
|
||||||
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
|
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
@@ -73,20 +86,31 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
|||||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, controller, name, arm_home_mode, partition_id, config_entry_id
|
self,
|
||||||
|
controller: AsyncSatel,
|
||||||
|
device_name: str,
|
||||||
|
arm_home_mode: int,
|
||||||
|
partition_id: int,
|
||||||
|
config_entry_id: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the alarm panel."""
|
"""Initialize the alarm panel."""
|
||||||
self._attr_name = name
|
|
||||||
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
|
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
|
||||||
self._arm_home_mode = arm_home_mode
|
self._arm_home_mode = arm_home_mode
|
||||||
self._partition_id = partition_id
|
self._partition_id = partition_id
|
||||||
self._satel = controller
|
self._satel = controller
|
||||||
|
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
|
||||||
|
)
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Update alarm status and register callbacks for future updates."""
|
"""Update alarm status and register callbacks for future updates."""
|
||||||
_LOGGER.debug("Starts listening for panel messages")
|
self._attr_alarm_state = self._read_alarm_state()
|
||||||
self._update_alarm_status()
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
|
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
|
||||||
@@ -94,55 +118,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_alarm_status(self):
|
def _update_alarm_status(self) -> None:
|
||||||
"""Handle alarm status update."""
|
"""Handle alarm status update."""
|
||||||
state = self._read_alarm_state()
|
state = self._read_alarm_state()
|
||||||
_LOGGER.debug("Got status update, current status: %s", state)
|
|
||||||
if state != self._attr_alarm_state:
|
if state != self._attr_alarm_state:
|
||||||
self._attr_alarm_state = state
|
self._attr_alarm_state = state
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
else:
|
|
||||||
_LOGGER.debug("Ignoring alarm status message, same state")
|
|
||||||
|
|
||||||
def _read_alarm_state(self):
|
def _read_alarm_state(self) -> AlarmControlPanelState | None:
|
||||||
"""Read current status of the alarm and translate it into HA status."""
|
"""Read current status of the alarm and translate it into HA status."""
|
||||||
|
|
||||||
# Default - disarmed:
|
|
||||||
hass_alarm_status = AlarmControlPanelState.DISARMED
|
|
||||||
|
|
||||||
if not self._satel.connected:
|
if not self._satel.connected:
|
||||||
|
_LOGGER.debug("Alarm panel not connected")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
state_map = OrderedDict(
|
for satel_state, ha_state in ALARM_STATE_MAP.items():
|
||||||
[
|
|
||||||
(AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED),
|
|
||||||
(AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED),
|
|
||||||
(AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING),
|
|
||||||
(AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME),
|
|
||||||
(AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME),
|
|
||||||
(AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME),
|
|
||||||
(AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY),
|
|
||||||
(
|
|
||||||
AlarmState.EXIT_COUNTDOWN_OVER_10,
|
|
||||||
AlarmControlPanelState.PENDING,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
AlarmState.EXIT_COUNTDOWN_UNDER_10,
|
|
||||||
AlarmControlPanelState.PENDING,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
_LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
|
|
||||||
|
|
||||||
for satel_state, ha_state in state_map.items():
|
|
||||||
if (
|
if (
|
||||||
satel_state in self._satel.partition_states
|
satel_state in self._satel.partition_states
|
||||||
and self._partition_id in self._satel.partition_states[satel_state]
|
and self._partition_id in self._satel.partition_states[satel_state]
|
||||||
):
|
):
|
||||||
hass_alarm_status = ha_state
|
return ha_state
|
||||||
break
|
|
||||||
|
|
||||||
return hass_alarm_status
|
return AlarmControlPanelState.DISARMED
|
||||||
|
|
||||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||||
"""Send disarm command."""
|
"""Send disarm command."""
|
||||||
@@ -154,8 +152,6 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
|||||||
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
|
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
|
||||||
)
|
)
|
||||||
|
|
||||||
_LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state)
|
|
||||||
|
|
||||||
await self._satel.disarm(code, [self._partition_id])
|
await self._satel.disarm(code, [self._partition_id])
|
||||||
|
|
||||||
if clear_alarm_necessary:
|
if clear_alarm_necessary:
|
||||||
@@ -165,14 +161,12 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
|
|||||||
|
|
||||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||||
"""Send arm away command."""
|
"""Send arm away command."""
|
||||||
_LOGGER.debug("Arming away")
|
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
await self._satel.arm(code, [self._partition_id])
|
await self._satel.arm(code, [self._partition_id])
|
||||||
|
|
||||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||||
"""Send arm home command."""
|
"""Send arm home command."""
|
||||||
_LOGGER.debug("Arming home")
|
|
||||||
|
|
||||||
if code:
|
if code:
|
||||||
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
|
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from satel_integra.satel_integra import AsyncSatel
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
)
|
)
|
||||||
from homeassistant.const import CONF_NAME
|
from homeassistant.const import CONF_NAME
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
|
|
||||||
@@ -17,6 +20,7 @@ from .const import (
|
|||||||
CONF_ZONE_NUMBER,
|
CONF_ZONE_NUMBER,
|
||||||
CONF_ZONE_TYPE,
|
CONF_ZONE_TYPE,
|
||||||
CONF_ZONES,
|
CONF_ZONES,
|
||||||
|
DOMAIN,
|
||||||
SIGNAL_OUTPUTS_UPDATED,
|
SIGNAL_OUTPUTS_UPDATED,
|
||||||
SIGNAL_ZONES_UPDATED,
|
SIGNAL_ZONES_UPDATED,
|
||||||
SUBENTRY_TYPE_OUTPUT,
|
SUBENTRY_TYPE_OUTPUT,
|
||||||
@@ -40,9 +44,9 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for subentry in zone_subentries:
|
for subentry in zone_subentries:
|
||||||
zone_num = subentry.data[CONF_ZONE_NUMBER]
|
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
|
||||||
zone_type = subentry.data[CONF_ZONE_TYPE]
|
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||||
zone_name = subentry.data[CONF_NAME]
|
zone_name: str = subentry.data[CONF_NAME]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
@@ -65,9 +69,9 @@ async def async_setup_entry(
|
|||||||
)
|
)
|
||||||
|
|
||||||
for subentry in output_subentries:
|
for subentry in output_subentries:
|
||||||
output_num = subentry.data[CONF_OUTPUT_NUMBER]
|
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
|
||||||
ouput_type = subentry.data[CONF_ZONE_TYPE]
|
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
|
||||||
output_name = subentry.data[CONF_NAME]
|
output_name: str = subentry.data[CONF_NAME]
|
||||||
|
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
[
|
[
|
||||||
@@ -89,68 +93,48 @@ class SatelIntegraBinarySensor(BinarySensorEntity):
|
|||||||
"""Representation of an Satel Integra binary sensor."""
|
"""Representation of an Satel Integra binary sensor."""
|
||||||
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_name = None
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
controller,
|
controller: AsyncSatel,
|
||||||
device_number,
|
device_number: int,
|
||||||
device_name,
|
device_name: str,
|
||||||
zone_type,
|
device_class: BinarySensorDeviceClass,
|
||||||
sensor_type,
|
sensor_type: str,
|
||||||
react_to_signal,
|
react_to_signal: str,
|
||||||
config_entry_id,
|
config_entry_id: str,
|
||||||
):
|
) -> None:
|
||||||
"""Initialize the binary_sensor."""
|
"""Initialize the binary_sensor."""
|
||||||
self._device_number = device_number
|
self._device_number = device_number
|
||||||
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{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._react_to_signal = react_to_signal
|
||||||
self._satel = controller
|
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:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Register callbacks."""
|
"""Register callbacks."""
|
||||||
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
|
if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
|
||||||
if self._device_number in self._satel.violated_outputs:
|
self._attr_is_on = self._device_number in self._satel.violated_outputs
|
||||||
self._state = 1
|
|
||||||
else:
|
else:
|
||||||
self._state = 0
|
self._attr_is_on = self._device_number in self._satel.violated_zones
|
||||||
elif self._device_number in self._satel.violated_zones:
|
|
||||||
self._state = 1
|
|
||||||
else:
|
|
||||||
self._state = 0
|
|
||||||
self.async_on_remove(
|
self.async_on_remove(
|
||||||
async_dispatcher_connect(
|
async_dispatcher_connect(
|
||||||
self.hass, self._react_to_signal, self._devices_updated
|
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
|
@callback
|
||||||
def _devices_updated(self, zones):
|
def _devices_updated(self, zones: dict[int, int]):
|
||||||
"""Update the zone's state, if needed."""
|
"""Update the zone's state, if needed."""
|
||||||
if self._device_number in zones and self._state != zones[self._device_number]:
|
if self._device_number in zones:
|
||||||
self._state = zones[self._device_number]
|
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()
|
self.async_write_ha_state()
|
||||||
|
|||||||
@@ -118,6 +118,9 @@
|
|||||||
"pm25": {
|
"pm25": {
|
||||||
"default": "mdi:molecule"
|
"default": "mdi:molecule"
|
||||||
},
|
},
|
||||||
|
"pm4": {
|
||||||
|
"default": "mdi:molecule"
|
||||||
|
},
|
||||||
"power": {
|
"power": {
|
||||||
"default": "mdi:flash"
|
"default": "mdi:flash"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,10 +12,11 @@ from homeassistant.config_entries import ConfigEntry
|
|||||||
from homeassistant.const import Platform
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import ConfigEntryNotReady
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import config_validation as cv, httpx_client
|
||||||
config_entry_oauth2_flow,
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||||
config_validation as cv,
|
ImplementationUnavailableError,
|
||||||
httpx_client,
|
OAuth2Session,
|
||||||
|
async_get_config_entry_implementation,
|
||||||
)
|
)
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
@@ -28,19 +29,22 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
|
|
||||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||||
|
|
||||||
PLATFORMS = [Platform.CLIMATE]
|
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||||
|
|
||||||
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
|
||||||
|
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
|
||||||
"""Set up SENZ from a config entry."""
|
"""Set up SENZ from a config entry."""
|
||||||
implementation = (
|
try:
|
||||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||||
hass, entry
|
except ImplementationUnavailableError as err:
|
||||||
)
|
raise ConfigEntryNotReady(
|
||||||
)
|
translation_domain=DOMAIN,
|
||||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
translation_key="oauth2_implementation_unavailable",
|
||||||
|
) from err
|
||||||
|
session = OAuth2Session(hass, entry, implementation)
|
||||||
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
|
auth = SENZConfigEntryAuth(httpx_client.get_async_client(hass), session)
|
||||||
senz_api = SENZAPI(auth)
|
senz_api = SENZAPI(auth)
|
||||||
|
|
||||||
@@ -68,16 +72,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
|
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
|
||||||
|
|
||||||
return unload_ok
|
|
||||||
|
|||||||
@@ -12,30 +12,29 @@ from homeassistant.components.climate import (
|
|||||||
HVACAction,
|
HVACAction,
|
||||||
HVACMode,
|
HVACMode,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import SENZDataUpdateCoordinator
|
from . import SENZConfigEntry, SENZDataUpdateCoordinator
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: SENZConfigEntry,
|
||||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the SENZ climate entities from a config entry."""
|
"""Set up the SENZ climate entities from a config entry."""
|
||||||
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = entry.runtime_data
|
||||||
async_add_entities(
|
async_add_entities(
|
||||||
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
|
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class SENZClimate(CoordinatorEntity, ClimateEntity):
|
class SENZClimate(CoordinatorEntity[SENZDataUpdateCoordinator], ClimateEntity):
|
||||||
"""Representation of a SENZ climate entity."""
|
"""Representation of a SENZ climate entity."""
|
||||||
|
|
||||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||||
|
|||||||
26
homeassistant/components/senz/diagnostics.py
Normal file
26
homeassistant/components/senz/diagnostics.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"""Diagnostics platform for Senz integration."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from homeassistant.components.diagnostics import async_redact_data
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
|
from . import SENZConfigEntry
|
||||||
|
|
||||||
|
TO_REDACT = [
|
||||||
|
"access_token",
|
||||||
|
"refresh_token",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_get_config_entry_diagnostics(
|
||||||
|
hass: HomeAssistant, entry: SENZConfigEntry
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Return diagnostics for a config entry."""
|
||||||
|
|
||||||
|
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entry_data": async_redact_data(entry.data, TO_REDACT),
|
||||||
|
"thermostats": raw_data,
|
||||||
|
}
|
||||||
92
homeassistant/components/senz/sensor.py
Normal file
92
homeassistant/components/senz/sensor.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""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.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 SENZConfigEntry, 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: SENZConfigEntry,
|
||||||
|
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the SENZ sensor entities from a config entry."""
|
||||||
|
coordinator = entry.runtime_data
|
||||||
|
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,5 +25,10 @@
|
|||||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"oauth2_implementation_unavailable": {
|
||||||
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError
|
|||||||
|
|
||||||
from homeassistant.components.number import (
|
from homeassistant.components.number import (
|
||||||
DOMAIN as NUMBER_PLATFORM,
|
DOMAIN as NUMBER_PLATFORM,
|
||||||
|
NumberDeviceClass,
|
||||||
NumberEntity,
|
NumberEntity,
|
||||||
NumberEntityDescription,
|
NumberEntityDescription,
|
||||||
NumberExtraStoredData,
|
NumberExtraStoredData,
|
||||||
@@ -107,6 +108,9 @@ class RpcNumber(ShellyRpcAttributeEntity, NumberEntity):
|
|||||||
if description.mode_fn is not None:
|
if description.mode_fn is not None:
|
||||||
self._attr_mode = description.mode_fn(coordinator.device.config[key])
|
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
|
@property
|
||||||
def native_value(self) -> float | None:
|
def native_value(self) -> float | None:
|
||||||
"""Return value of number."""
|
"""Return value of number."""
|
||||||
@@ -181,7 +185,6 @@ NUMBERS: dict[tuple[str, str], BlockNumberDescription] = {
|
|||||||
("device", "valvePos"): BlockNumberDescription(
|
("device", "valvePos"): BlockNumberDescription(
|
||||||
key="device|valvepos",
|
key="device|valvepos",
|
||||||
translation_key="valve_position",
|
translation_key="valve_position",
|
||||||
name="Valve position",
|
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
available=lambda block: cast(int, block.valveError) != 1,
|
available=lambda block: cast(int, block.valveError) != 1,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
@@ -200,12 +203,12 @@ RPC_NUMBERS: Final = {
|
|||||||
key="blutrv",
|
key="blutrv",
|
||||||
sub_key="current_C",
|
sub_key="current_C",
|
||||||
translation_key="external_temperature",
|
translation_key="external_temperature",
|
||||||
name="External temperature",
|
|
||||||
native_min_value=-50,
|
native_min_value=-50,
|
||||||
native_max_value=50,
|
native_max_value=50,
|
||||||
native_step=0.1,
|
native_step=0.1,
|
||||||
mode=NumberMode.BOX,
|
mode=NumberMode.BOX,
|
||||||
entity_category=EntityCategory.CONFIG,
|
entity_category=EntityCategory.CONFIG,
|
||||||
|
device_class=NumberDeviceClass.TEMPERATURE,
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
method="blu_trv_set_external_temperature",
|
method="blu_trv_set_external_temperature",
|
||||||
entity_class=RpcBluTrvExtTempNumber,
|
entity_class=RpcBluTrvExtTempNumber,
|
||||||
@@ -213,7 +216,7 @@ RPC_NUMBERS: Final = {
|
|||||||
"number_generic": RpcNumberDescription(
|
"number_generic": RpcNumberDescription(
|
||||||
key="number",
|
key="number",
|
||||||
sub_key="value",
|
sub_key="value",
|
||||||
removal_condition=lambda config, _status, key: not is_view_for_platform(
|
removal_condition=lambda config, _, key: not is_view_for_platform(
|
||||||
config, key, NUMBER_PLATFORM
|
config, key, NUMBER_PLATFORM
|
||||||
),
|
),
|
||||||
max_fn=lambda config: config["max"],
|
max_fn=lambda config: config["max"],
|
||||||
@@ -229,9 +232,11 @@ RPC_NUMBERS: Final = {
|
|||||||
"number_current_limit": RpcNumberDescription(
|
"number_current_limit": RpcNumberDescription(
|
||||||
key="number",
|
key="number",
|
||||||
sub_key="value",
|
sub_key="value",
|
||||||
|
translation_key="current_limit",
|
||||||
|
device_class=NumberDeviceClass.CURRENT,
|
||||||
max_fn=lambda config: config["max"],
|
max_fn=lambda config: config["max"],
|
||||||
min_fn=lambda config: config["min"],
|
min_fn=lambda config: config["min"],
|
||||||
mode_fn=lambda config: NumberMode.SLIDER,
|
mode_fn=lambda _: NumberMode.SLIDER,
|
||||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||||
unit=get_virtual_component_unit,
|
unit=get_virtual_component_unit,
|
||||||
method="number_set",
|
method="number_set",
|
||||||
@@ -241,10 +246,11 @@ RPC_NUMBERS: Final = {
|
|||||||
"number_position": RpcNumberDescription(
|
"number_position": RpcNumberDescription(
|
||||||
key="number",
|
key="number",
|
||||||
sub_key="value",
|
sub_key="value",
|
||||||
|
translation_key="valve_position",
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
max_fn=lambda config: config["max"],
|
max_fn=lambda config: config["max"],
|
||||||
min_fn=lambda config: config["min"],
|
min_fn=lambda config: config["min"],
|
||||||
mode_fn=lambda config: NumberMode.SLIDER,
|
mode_fn=lambda _: NumberMode.SLIDER,
|
||||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||||
unit=get_virtual_component_unit,
|
unit=get_virtual_component_unit,
|
||||||
method="number_set",
|
method="number_set",
|
||||||
@@ -254,10 +260,12 @@ RPC_NUMBERS: Final = {
|
|||||||
"number_target_humidity": RpcNumberDescription(
|
"number_target_humidity": RpcNumberDescription(
|
||||||
key="number",
|
key="number",
|
||||||
sub_key="value",
|
sub_key="value",
|
||||||
|
translation_key="target_humidity",
|
||||||
|
device_class=NumberDeviceClass.HUMIDITY,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
max_fn=lambda config: config["max"],
|
max_fn=lambda config: config["max"],
|
||||||
min_fn=lambda config: config["min"],
|
min_fn=lambda config: config["min"],
|
||||||
mode_fn=lambda config: NumberMode.SLIDER,
|
mode_fn=lambda _: NumberMode.SLIDER,
|
||||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||||
unit=get_virtual_component_unit,
|
unit=get_virtual_component_unit,
|
||||||
method="number_set",
|
method="number_set",
|
||||||
@@ -267,10 +275,12 @@ RPC_NUMBERS: Final = {
|
|||||||
"number_target_temperature": RpcNumberDescription(
|
"number_target_temperature": RpcNumberDescription(
|
||||||
key="number",
|
key="number",
|
||||||
sub_key="value",
|
sub_key="value",
|
||||||
|
translation_key="target_temperature",
|
||||||
|
device_class=NumberDeviceClass.TEMPERATURE,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
max_fn=lambda config: config["max"],
|
max_fn=lambda config: config["max"],
|
||||||
min_fn=lambda config: config["min"],
|
min_fn=lambda config: config["min"],
|
||||||
mode_fn=lambda config: NumberMode.SLIDER,
|
mode_fn=lambda _: NumberMode.SLIDER,
|
||||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||||
unit=get_virtual_component_unit,
|
unit=get_virtual_component_unit,
|
||||||
method="number_set",
|
method="number_set",
|
||||||
@@ -281,21 +291,20 @@ RPC_NUMBERS: Final = {
|
|||||||
key="blutrv",
|
key="blutrv",
|
||||||
sub_key="pos",
|
sub_key="pos",
|
||||||
translation_key="valve_position",
|
translation_key="valve_position",
|
||||||
name="Valve position",
|
|
||||||
native_min_value=0,
|
native_min_value=0,
|
||||||
native_max_value=100,
|
native_max_value=100,
|
||||||
native_step=1,
|
native_step=1,
|
||||||
mode=NumberMode.SLIDER,
|
mode=NumberMode.SLIDER,
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
method="blu_trv_set_valve_position",
|
method="blu_trv_set_valve_position",
|
||||||
removal_condition=lambda config, _status, key: config[key].get("enable", True)
|
removal_condition=lambda config, _, key: config[key].get("enable", True)
|
||||||
is True,
|
is True,
|
||||||
entity_class=RpcBluTrvNumber,
|
entity_class=RpcBluTrvNumber,
|
||||||
),
|
),
|
||||||
"left_slot_intensity": RpcNumberDescription(
|
"left_slot_intensity": RpcNumberDescription(
|
||||||
key="cury",
|
key="cury",
|
||||||
sub_key="slots",
|
sub_key="slots",
|
||||||
name="Left slot intensity",
|
translation_key="left_slot_intensity",
|
||||||
value=lambda status, _: status["left"]["intensity"],
|
value=lambda status, _: status["left"]["intensity"],
|
||||||
native_min_value=0,
|
native_min_value=0,
|
||||||
native_max_value=100,
|
native_max_value=100,
|
||||||
@@ -311,7 +320,7 @@ RPC_NUMBERS: Final = {
|
|||||||
"right_slot_intensity": RpcNumberDescription(
|
"right_slot_intensity": RpcNumberDescription(
|
||||||
key="cury",
|
key="cury",
|
||||||
sub_key="slots",
|
sub_key="slots",
|
||||||
name="Right slot intensity",
|
translation_key="right_slot_intensity",
|
||||||
value=lambda status, _: status["right"]["intensity"],
|
value=lambda status, _: status["right"]["intensity"],
|
||||||
native_min_value=0,
|
native_min_value=0,
|
||||||
native_max_value=100,
|
native_max_value=100,
|
||||||
@@ -402,6 +411,9 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
|
|||||||
self.restored_data: NumberExtraStoredData | None = None
|
self.restored_data: NumberExtraStoredData | None = None
|
||||||
super().__init__(coordinator, block, attribute, description, entry)
|
super().__init__(coordinator, block, attribute, description, entry)
|
||||||
|
|
||||||
|
if hasattr(self, "_attr_name"):
|
||||||
|
delattr(self, "_attr_name")
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle entity which will be added."""
|
"""Handle entity which will be added."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|||||||
@@ -188,6 +188,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"number": {
|
||||||
|
"current_limit": {
|
||||||
|
"name": "Current limit"
|
||||||
|
},
|
||||||
|
"external_temperature": {
|
||||||
|
"name": "External temperature"
|
||||||
|
},
|
||||||
|
"left_slot_intensity": {
|
||||||
|
"name": "Left slot intensity"
|
||||||
|
},
|
||||||
|
"right_slot_intensity": {
|
||||||
|
"name": "Right slot intensity"
|
||||||
|
},
|
||||||
|
"target_humidity": {
|
||||||
|
"name": "Target humidity"
|
||||||
|
},
|
||||||
|
"target_temperature": {
|
||||||
|
"name": "Target temperature"
|
||||||
|
},
|
||||||
|
"valve_position": {
|
||||||
|
"name": "Valve position"
|
||||||
|
}
|
||||||
|
},
|
||||||
"select": {
|
"select": {
|
||||||
"cury_mode": {
|
"cury_mode": {
|
||||||
"name": "Mode",
|
"name": "Mode",
|
||||||
|
|||||||
@@ -30,5 +30,5 @@
|
|||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pysmartthings"],
|
"loggers": ["pysmartthings"],
|
||||||
"quality_scale": "bronze",
|
"quality_scale": "bronze",
|
||||||
"requirements": ["pysmartthings==3.3.1"]
|
"requirements": ["pysmartthings==3.3.2"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -663,7 +663,7 @@
|
|||||||
},
|
},
|
||||||
"exceptions": {
|
"exceptions": {
|
||||||
"oauth2_implementation_unavailable": {
|
"oauth2_implementation_unavailable": {
|
||||||
"message": "OAuth2 implementation unavailable, will retry"
|
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"issues": {
|
"issues": {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user