mirror of
https://github.com/home-assistant/core.git
synced 2026-01-01 03:31:58 +01:00
Compare commits
170 Commits
2025.7.3
...
enforce_en
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df446f5240 | ||
|
|
c92873bbff | ||
|
|
5fea4915ef | ||
|
|
8fa016059d | ||
|
|
61a29db72c | ||
|
|
5a3aa7874d | ||
|
|
12e2493c42 | ||
|
|
659cd42739 | ||
|
|
7fcea17e83 | ||
|
|
30a85c40da | ||
|
|
57a8f1e0cc | ||
|
|
78aeae577d | ||
|
|
3f95cb37e6 | ||
|
|
12aef4aae5 | ||
|
|
2e12db001d | ||
|
|
573325be97 | ||
|
|
7021fe7495 | ||
|
|
b7999755bd | ||
|
|
99f7a031d6 | ||
|
|
8fc31283b7 | ||
|
|
5ff698c78d | ||
|
|
9469c6ad1c | ||
|
|
35f0505c7b | ||
|
|
a180cabea9 | ||
|
|
4f7348b8bc | ||
|
|
ddf56f053b | ||
|
|
9719d2ef2b | ||
|
|
2afe475234 | ||
|
|
23c304fc75 | ||
|
|
84645d0ca6 | ||
|
|
2bdfc8cf5e | ||
|
|
603e277a5b | ||
|
|
38a7b21052 | ||
|
|
bf74ba990a | ||
|
|
70856bd92a | ||
|
|
be6b624081 | ||
|
|
217fbb2849 | ||
|
|
22a14da19c | ||
|
|
20f5d85800 | ||
|
|
88feb5139b | ||
|
|
90cbe272a0 | ||
|
|
511b739bf6 | ||
|
|
9961a499ee | ||
|
|
d8c7ed473b | ||
|
|
2c30a5a14c | ||
|
|
5e3fc858d8 | ||
|
|
f03af213d4 | ||
|
|
1e3ebd5650 | ||
|
|
53936ab062 | ||
|
|
b52a248def | ||
|
|
ea70229426 | ||
|
|
741a3d5009 | ||
|
|
ee8830cc77 | ||
|
|
7fbf25e862 | ||
|
|
e642cd45ae | ||
|
|
179e1c2b00 | ||
|
|
52a99aea0c | ||
|
|
c7b2f236be | ||
|
|
a6e3da43ca | ||
|
|
4d58024d5d | ||
|
|
c7603b39ec | ||
|
|
c17ee0d123 | ||
|
|
97c1e21a69 | ||
|
|
c9a6b1fd45 | ||
|
|
05ceee568e | ||
|
|
08a6b38699 | ||
|
|
4add346272 | ||
|
|
369c8d1e0d | ||
|
|
25ab47a587 | ||
|
|
617ea1925c | ||
|
|
8bacab4f9c | ||
|
|
6d28b99344 | ||
|
|
bbd1cbf5c9 | ||
|
|
43450d4489 | ||
|
|
f8c052e0ce | ||
|
|
1f3bdfc7b7 | ||
|
|
0652bffd68 | ||
|
|
8322611099 | ||
|
|
134967b817 | ||
|
|
39abae36f0 | ||
|
|
227760f203 | ||
|
|
969809456e | ||
|
|
d2e8a48b2c | ||
|
|
ea6332ee42 | ||
|
|
91c3b43d7f | ||
|
|
1d82d44794 | ||
|
|
571376badc | ||
|
|
32236b2f4d | ||
|
|
18c1953bc5 | ||
|
|
d874c28dc9 | ||
|
|
19d89c8952 | ||
|
|
e3ba1f34ca | ||
|
|
b630fb0520 | ||
|
|
5129f89086 | ||
|
|
0be0e22e76 | ||
|
|
b8500b338a | ||
|
|
4cab3a0465 | ||
|
|
ff711324d5 | ||
|
|
113e7dc003 | ||
|
|
2120ff6a0a | ||
|
|
8ee5c30754 | ||
|
|
a1518b96c4 | ||
|
|
bba7f5c3f0 | ||
|
|
8a5671af76 | ||
|
|
8a18dea8c7 | ||
|
|
4b02f22724 | ||
|
|
7229c2ca2c | ||
|
|
d83eddf13b | ||
|
|
4a192a7b09 | ||
|
|
58c434887e | ||
|
|
78c2405e61 | ||
|
|
8cc4105984 | ||
|
|
917f1e4c6f | ||
|
|
3879f6d2ef | ||
|
|
78060e4833 | ||
|
|
fda66c4be4 | ||
|
|
21131d00b3 | ||
|
|
a84313de33 | ||
|
|
c73346e6b3 | ||
|
|
55a37a2936 | ||
|
|
e481f14335 | ||
|
|
1ca03c8ae9 | ||
|
|
61b43ca1fc | ||
|
|
1b2be083c2 | ||
|
|
4bdf3d6f30 | ||
|
|
43535ede8b | ||
|
|
9bd0762799 | ||
|
|
1bb653b4f7 | ||
|
|
2655edcfc8 | ||
|
|
7a08edc3dd | ||
|
|
b3131355b0 | ||
|
|
06d04c001d | ||
|
|
babecdf32c | ||
|
|
17cd39748b | ||
|
|
c2f1e86a4e | ||
|
|
61a32466b6 | ||
|
|
aef08091f8 | ||
|
|
1416f0f1e0 | ||
|
|
af7b1a76bc | ||
|
|
bf88fcd5bf | ||
|
|
35478e3162 | ||
|
|
69af74a593 | ||
|
|
b4dd912bee | ||
|
|
b5821ef499 | ||
|
|
1a92d4530e | ||
|
|
7b80c1c693 | ||
|
|
e7cc03c1d9 | ||
|
|
69f0b6244a | ||
|
|
01205f8a14 | ||
|
|
68924d23ab | ||
|
|
40f553a007 | ||
|
|
bc46894b74 | ||
|
|
6f4615f012 | ||
|
|
4244d2f66f | ||
|
|
a73dafe097 | ||
|
|
be49296547 | ||
|
|
d55ecd885e | ||
|
|
076248c455 | ||
|
|
13ce27c94c | ||
|
|
4b9b08ece5 | ||
|
|
79df38eff2 | ||
|
|
fb133664e4 | ||
|
|
38669ce96c | ||
|
|
651b33d49b | ||
|
|
3b64db5f76 | ||
|
|
0f95fe566c | ||
|
|
6290facffb | ||
|
|
f0a78aadbe | ||
|
|
345ec97dd5 | ||
|
|
1286b5d9d8 |
1235
.github/copilot-instructions.md
vendored
1235
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -37,10 +37,10 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CACHE_VERSION: 4
|
||||
CACHE_VERSION: 3
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2025.7"
|
||||
HA_SHORT_VERSION: "2025.8"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.29.0
|
||||
uses: github/codeql-action/init@v3.29.2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.29.0
|
||||
uses: github/codeql-action/analyze@v3.29.2
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -137,4 +137,8 @@ tmp_cache
|
||||
.ropeproject
|
||||
|
||||
# Will be created from script/split_tests.py
|
||||
pytest_buckets.txt
|
||||
pytest_buckets.txt
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.0
|
||||
rev: v0.12.1
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
args:
|
||||
|
||||
@@ -75,7 +75,6 @@ from .core_config import async_process_ha_core_config
|
||||
from .exceptions import HomeAssistantError
|
||||
from .helpers import (
|
||||
area_registry,
|
||||
backup,
|
||||
category_registry,
|
||||
config_validation as cv,
|
||||
device_registry,
|
||||
@@ -607,7 +606,7 @@ async def async_enable_logging(
|
||||
)
|
||||
threading.excepthook = lambda args: logging.getLogger().exception(
|
||||
"Uncaught thread exception",
|
||||
exc_info=( # type: ignore[arg-type] # noqa: LOG014
|
||||
exc_info=( # type: ignore[arg-type]
|
||||
args.exc_type,
|
||||
args.exc_value,
|
||||
args.exc_traceback,
|
||||
@@ -880,10 +879,6 @@ async def _async_set_up_integrations(
|
||||
if "recorder" in all_domains:
|
||||
recorder.async_initialize_recorder(hass)
|
||||
|
||||
# Initialize backup
|
||||
if "backup" in all_domains:
|
||||
backup.async_initialize_backup(hass)
|
||||
|
||||
stages: list[tuple[str, set[str], int | None]] = [
|
||||
*(
|
||||
(name, domain_group, timeout)
|
||||
@@ -1061,5 +1056,5 @@ async def _async_setup_multi_components(
|
||||
_LOGGER.error(
|
||||
"Error setting up integration %s - received exception",
|
||||
domain,
|
||||
exc_info=(type(result), result, result.__traceback__), # noqa: LOG014
|
||||
exc_info=(type(result), result, result.__traceback__),
|
||||
)
|
||||
|
||||
@@ -505,13 +505,8 @@ class ClimateCapabilities(AlexaEntity):
|
||||
):
|
||||
yield AlexaThermostatController(self.hass, self.entity)
|
||||
yield AlexaTemperatureSensor(self.hass, self.entity)
|
||||
if (
|
||||
self.entity.domain == water_heater.DOMAIN
|
||||
and (
|
||||
supported_features
|
||||
& water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
||||
)
|
||||
and self.entity.attributes.get(water_heater.ATTR_OPERATION_LIST)
|
||||
if self.entity.domain == water_heater.DOMAIN and (
|
||||
supported_features & water_heater.WaterHeaterEntityFeature.OPERATION_MODE
|
||||
):
|
||||
yield AlexaModeController(
|
||||
self.entity,
|
||||
@@ -639,9 +634,7 @@ class FanCapabilities(AlexaEntity):
|
||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_OSCILLATING}"
|
||||
)
|
||||
force_range_controller = False
|
||||
if supported & fan.FanEntityFeature.PRESET_MODE and self.entity.attributes.get(
|
||||
fan.ATTR_PRESET_MODES
|
||||
):
|
||||
if supported & fan.FanEntityFeature.PRESET_MODE:
|
||||
yield AlexaModeController(
|
||||
self.entity, instance=f"{fan.DOMAIN}.{fan.ATTR_PRESET_MODE}"
|
||||
)
|
||||
@@ -679,11 +672,7 @@ class RemoteCapabilities(AlexaEntity):
|
||||
yield AlexaPowerController(self.entity)
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
activities = self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST) or []
|
||||
if (
|
||||
activities
|
||||
and (supported & remote.RemoteEntityFeature.ACTIVITY)
|
||||
and self.entity.attributes.get(remote.ATTR_ACTIVITY_LIST)
|
||||
):
|
||||
if activities and supported & remote.RemoteEntityFeature.ACTIVITY:
|
||||
yield AlexaModeController(
|
||||
self.entity, instance=f"{remote.DOMAIN}.{remote.ATTR_ACTIVITY}"
|
||||
)
|
||||
@@ -703,9 +692,7 @@ class HumidifierCapabilities(AlexaEntity):
|
||||
"""Yield the supported interfaces."""
|
||||
yield AlexaPowerController(self.entity)
|
||||
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if (
|
||||
supported & humidifier.HumidifierEntityFeature.MODES
|
||||
) and self.entity.attributes.get(humidifier.ATTR_AVAILABLE_MODES):
|
||||
if supported & humidifier.HumidifierEntityFeature.MODES:
|
||||
yield AlexaModeController(
|
||||
self.entity, instance=f"{humidifier.DOMAIN}.{humidifier.ATTR_MODE}"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect, WrongCountry
|
||||
from aioamazondevices.exceptions import CannotAuthenticate, CannotConnect
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -36,8 +36,6 @@ class AmazonDevicesConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotAuthenticate:
|
||||
errors["base"] = "invalid_auth"
|
||||
except WrongCountry:
|
||||
errors["base"] = "wrong_country"
|
||||
else:
|
||||
await self.async_set_unique_id(data["customer_info"]["user_id"])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aioamazondevices==3.5.0"]
|
||||
"requirements": ["aioamazondevices==3.1.22"]
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"wrong_country": "Wrong country selected. Please select the country where your Amazon account is registered.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -61,6 +61,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(async_update_options))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -69,6 +71,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_update_options(
|
||||
hass: HomeAssistant, entry: AnthropicConfigEntry
|
||||
) -> None:
|
||||
"""Update options."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
async def async_migrate_integration(hass: HomeAssistant) -> None:
|
||||
"""Migrate integration entry structure."""
|
||||
|
||||
|
||||
@@ -1,69 +1,17 @@
|
||||
"""Conversation support for Anthropic."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
import json
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
from typing import Literal
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, intent, llm
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
THINKING_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
from .const import CONF_PROMPT, DOMAIN
|
||||
from .entity import AnthropicBaseLLMEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -82,253 +30,10 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ToolParam:
|
||||
"""Format tool specification."""
|
||||
return ToolParam(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
)
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
tool_result_block,
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
|
||||
elif isinstance(content, conversation.UserContent):
|
||||
# Combine consequent user messages
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=content.content,
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
TextBlockParam(type="text", text=content.content),
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append( # type: ignore[attr-defined]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
elif isinstance(content, conversation.AssistantContent):
|
||||
# Combine consequent assistant messages
|
||||
if not messages or messages[-1]["role"] != "assistant":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="assistant",
|
||||
content=[],
|
||||
)
|
||||
)
|
||||
|
||||
if content.content:
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
if content.tool_calls:
|
||||
messages[-1]["content"].extend( # type: ignore[union-attr]
|
||||
[
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
messages: list[MessageParam],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- ...
|
||||
- RawContentBlockDeltaEvent with a SignatureDelta
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
|
||||
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_message: MessageParam | None = None
|
||||
current_block: (
|
||||
TextBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ThinkingBlockParam
|
||||
| RedactedThinkingBlockParam
|
||||
| None
|
||||
) = None
|
||||
current_tool_args: str
|
||||
input_usage: Usage | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
current_message = MessageParam(role=response.message.role, content=[])
|
||||
input_usage = response.message.usage
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
current_block = TextBlockParam(
|
||||
type="text", text=response.content_block.text
|
||||
)
|
||||
yield {"role": "assistant"}
|
||||
if response.content_block.text:
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
current_block = ThinkingBlockParam(
|
||||
type="thinking",
|
||||
thinking=response.content_block.thinking,
|
||||
signature=response.content_block.signature,
|
||||
)
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
current_block = RedactedThinkingBlockParam(
|
||||
type="redacted_thinking", data=response.content_block.data
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected delta without a block")
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
text_block = cast(TextBlockParam, current_block)
|
||||
text_block["text"] += response.delta.text
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["thinking"] += response.delta.thinking
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["signature"] += response.delta.signature
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
current_message["content"].append(current_block) # type: ignore[union-attr]
|
||||
current_block = None
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
current_message = None
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AnthropicConversationEntity(
|
||||
conversation.ConversationEntity, conversation.AbstractConversationAgent
|
||||
conversation.ConversationEntity,
|
||||
conversation.AbstractConversationAgent,
|
||||
AnthropicBaseLLMEntity,
|
||||
):
|
||||
"""Anthropic conversation agent."""
|
||||
|
||||
@@ -336,17 +41,7 @@ class AnthropicConversationEntity(
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
super().__init__(entry, subentry)
|
||||
if self.subentry.data.get(CONF_LLM_HASS_API):
|
||||
self._attr_supported_features = (
|
||||
conversation.ConversationEntityFeature.CONTROL
|
||||
@@ -357,13 +52,6 @@ class AnthropicConversationEntity(
|
||||
"""Return a list of supported languages."""
|
||||
return MATCH_ALL
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
self.entry.async_on_unload(
|
||||
self.entry.add_update_listener(self._async_entry_update_listener)
|
||||
)
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
@@ -394,77 +82,3 @@ class AnthropicConversationEntity(
|
||||
conversation_id=chat_log.conversation_id,
|
||||
continue_conversation=chat_log.continue_conversation,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools or NOT_GIVEN,
|
||||
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
"system": system.content,
|
||||
"stream": True,
|
||||
}
|
||||
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
type="enabled", budget_tokens=thinking_budget
|
||||
)
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream, messages),
|
||||
)
|
||||
if not isinstance(content, conversation.AssistantContent)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
self, hass: HomeAssistant, entry: ConfigEntry
|
||||
) -> None:
|
||||
"""Handle options update."""
|
||||
# Reload as we update device info + entity name + supported features
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
393
homeassistant/components/anthropic/entity.py
Normal file
393
homeassistant/components/anthropic/entity.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""Base entity for Anthropic."""
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
import json
|
||||
from typing import Any, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic._types import NOT_GIVEN
|
||||
from anthropic.types import (
|
||||
InputJSONDelta,
|
||||
MessageDeltaUsage,
|
||||
MessageParam,
|
||||
MessageStreamEvent,
|
||||
RawContentBlockDeltaEvent,
|
||||
RawContentBlockStartEvent,
|
||||
RawContentBlockStopEvent,
|
||||
RawMessageDeltaEvent,
|
||||
RawMessageStartEvent,
|
||||
RawMessageStopEvent,
|
||||
RedactedThinkingBlock,
|
||||
RedactedThinkingBlockParam,
|
||||
SignatureDelta,
|
||||
TextBlock,
|
||||
TextBlockParam,
|
||||
TextDelta,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigDisabledParam,
|
||||
ThinkingConfigEnabledParam,
|
||||
ThinkingDelta,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import conversation
|
||||
from homeassistant.config_entries import ConfigSubentry
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
DOMAIN,
|
||||
LOGGER,
|
||||
MIN_THINKING_BUDGET,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
RECOMMENDED_MAX_TOKENS,
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_THINKING_BUDGET,
|
||||
THINKING_MODELS,
|
||||
)
|
||||
|
||||
# Max number of back and forth with the LLM to generate a response
|
||||
MAX_TOOL_ITERATIONS = 10
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> ToolParam:
|
||||
"""Format tool specification."""
|
||||
return ToolParam(
|
||||
name=tool.name,
|
||||
description=tool.description or "",
|
||||
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
)
|
||||
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json.dumps(content.tool_result),
|
||||
)
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=[tool_result_block],
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
tool_result_block,
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append(tool_result_block) # type: ignore[attr-defined]
|
||||
elif isinstance(content, conversation.UserContent):
|
||||
# Combine consequent user messages
|
||||
if not messages or messages[-1]["role"] != "user":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="user",
|
||||
content=content.content,
|
||||
)
|
||||
)
|
||||
elif isinstance(messages[-1]["content"], str):
|
||||
messages[-1]["content"] = [
|
||||
TextBlockParam(type="text", text=messages[-1]["content"]),
|
||||
TextBlockParam(type="text", text=content.content),
|
||||
]
|
||||
else:
|
||||
messages[-1]["content"].append( # type: ignore[attr-defined]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
elif isinstance(content, conversation.AssistantContent):
|
||||
# Combine consequent assistant messages
|
||||
if not messages or messages[-1]["role"] != "assistant":
|
||||
messages.append(
|
||||
MessageParam(
|
||||
role="assistant",
|
||||
content=[],
|
||||
)
|
||||
)
|
||||
|
||||
if content.content:
|
||||
messages[-1]["content"].append( # type: ignore[union-attr]
|
||||
TextBlockParam(type="text", text=content.content)
|
||||
)
|
||||
if content.tool_calls:
|
||||
messages[-1]["content"].extend( # type: ignore[union-attr]
|
||||
[
|
||||
ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
name=tool_call.tool_name,
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
chat_log: conversation.ChatLog,
|
||||
result: AsyncStream[MessageStreamEvent],
|
||||
messages: list[MessageParam],
|
||||
) -> AsyncGenerator[conversation.AssistantContentDeltaDict]:
|
||||
"""Transform the response stream into HA format.
|
||||
|
||||
A typical stream of responses might look something like the following:
|
||||
- RawMessageStartEvent with no content
|
||||
- RawContentBlockStartEvent with an empty ThinkingBlock (if extended thinking is enabled)
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- RawContentBlockDeltaEvent with a ThinkingDelta
|
||||
- ...
|
||||
- RawContentBlockDeltaEvent with a SignatureDelta
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with a RedactedThinkingBlock (occasionally)
|
||||
- RawContentBlockStopEvent (RedactedThinkingBlock does not have a delta)
|
||||
- RawContentBlockStartEvent with an empty TextBlock
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- RawContentBlockDeltaEvent with a TextDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawContentBlockStartEvent with ToolUseBlock specifying the function name
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- RawContentBlockDeltaEvent with a InputJSONDelta
|
||||
- ...
|
||||
- RawContentBlockStopEvent
|
||||
- RawMessageDeltaEvent with a stop_reason='tool_use'
|
||||
- RawMessageStopEvent(type='message_stop')
|
||||
|
||||
Each message could contain multiple blocks of the same type.
|
||||
"""
|
||||
if result is None:
|
||||
raise TypeError("Expected a stream of messages")
|
||||
|
||||
current_message: MessageParam | None = None
|
||||
current_block: (
|
||||
TextBlockParam
|
||||
| ToolUseBlockParam
|
||||
| ThinkingBlockParam
|
||||
| RedactedThinkingBlockParam
|
||||
| None
|
||||
) = None
|
||||
current_tool_args: str
|
||||
input_usage: Usage | None = None
|
||||
|
||||
async for response in result:
|
||||
LOGGER.debug("Received response: %s", response)
|
||||
|
||||
if isinstance(response, RawMessageStartEvent):
|
||||
if response.message.role != "assistant":
|
||||
raise ValueError("Unexpected message role")
|
||||
current_message = MessageParam(role=response.message.role, content=[])
|
||||
input_usage = response.message.usage
|
||||
elif isinstance(response, RawContentBlockStartEvent):
|
||||
if isinstance(response.content_block, ToolUseBlock):
|
||||
current_block = ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=response.content_block.id,
|
||||
name=response.content_block.name,
|
||||
input="",
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, TextBlock):
|
||||
current_block = TextBlockParam(
|
||||
type="text", text=response.content_block.text
|
||||
)
|
||||
yield {"role": "assistant"}
|
||||
if response.content_block.text:
|
||||
yield {"content": response.content_block.text}
|
||||
elif isinstance(response.content_block, ThinkingBlock):
|
||||
current_block = ThinkingBlockParam(
|
||||
type="thinking",
|
||||
thinking=response.content_block.thinking,
|
||||
signature=response.content_block.signature,
|
||||
)
|
||||
elif isinstance(response.content_block, RedactedThinkingBlock):
|
||||
current_block = RedactedThinkingBlockParam(
|
||||
type="redacted_thinking", data=response.content_block.data
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Some of Claude’s internal reasoning has been automatically "
|
||||
"encrypted for safety reasons. This doesn’t affect the quality of "
|
||||
"responses"
|
||||
)
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected delta without a block")
|
||||
if isinstance(response.delta, InputJSONDelta):
|
||||
current_tool_args += response.delta.partial_json
|
||||
elif isinstance(response.delta, TextDelta):
|
||||
text_block = cast(TextBlockParam, current_block)
|
||||
text_block["text"] += response.delta.text
|
||||
yield {"content": response.delta.text}
|
||||
elif isinstance(response.delta, ThinkingDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["thinking"] += response.delta.thinking
|
||||
elif isinstance(response.delta, SignatureDelta):
|
||||
thinking_block = cast(ThinkingBlockParam, current_block)
|
||||
thinking_block["signature"] += response.delta.signature
|
||||
elif isinstance(response, RawContentBlockStopEvent):
|
||||
if current_block is None:
|
||||
raise ValueError("Unexpected stop event without a current block")
|
||||
if current_block["type"] == "tool_use":
|
||||
# tool block
|
||||
tool_args = json.loads(current_tool_args) if current_tool_args else {}
|
||||
current_block["input"] = tool_args
|
||||
yield {
|
||||
"tool_calls": [
|
||||
llm.ToolInput(
|
||||
id=current_block["id"],
|
||||
tool_name=current_block["name"],
|
||||
tool_args=tool_args,
|
||||
)
|
||||
]
|
||||
}
|
||||
elif current_block["type"] == "thinking":
|
||||
# thinking block
|
||||
LOGGER.debug("Thinking: %s", current_block["thinking"])
|
||||
|
||||
if current_message is None:
|
||||
raise ValueError("Unexpected stop event without a current message")
|
||||
current_message["content"].append(current_block) # type: ignore[union-attr]
|
||||
current_block = None
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
if current_message is not None:
|
||||
messages.append(current_message)
|
||||
current_message = None
|
||||
|
||||
|
||||
def _create_token_stats(
|
||||
input_usage: Usage | None, response_usage: MessageDeltaUsage
|
||||
) -> dict[str, Any]:
|
||||
"""Create token stats for conversation agent tracing."""
|
||||
input_tokens = 0
|
||||
cached_input_tokens = 0
|
||||
if input_usage:
|
||||
input_tokens = input_usage.input_tokens
|
||||
cached_input_tokens = input_usage.cache_creation_input_tokens or 0
|
||||
output_tokens = response_usage.output_tokens
|
||||
return {
|
||||
"stats": {
|
||||
"input_tokens": input_tokens,
|
||||
"cached_input_tokens": cached_input_tokens,
|
||||
"output_tokens": output_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AnthropicBaseLLMEntity(Entity):
|
||||
"""Anthropic base LLM entity."""
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
self._attr_name = subentry.title
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model="Claude",
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
async def _async_handle_chat_log(
|
||||
self,
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> None:
|
||||
"""Generate an answer for the chat log."""
|
||||
options = self.subentry.data
|
||||
|
||||
tools: list[ToolParam] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
system = chat_log.content[0]
|
||||
if not isinstance(system, conversation.SystemContent):
|
||||
raise TypeError("First message must be a system message")
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
|
||||
client = self.entry.runtime_data
|
||||
|
||||
thinking_budget = options.get(CONF_THINKING_BUDGET, RECOMMENDED_THINKING_BUDGET)
|
||||
model = options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
model_args = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"tools": tools or NOT_GIVEN,
|
||||
"max_tokens": options.get(CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS),
|
||||
"system": system.content,
|
||||
"stream": True,
|
||||
}
|
||||
if model in THINKING_MODELS and thinking_budget >= MIN_THINKING_BUDGET:
|
||||
model_args["thinking"] = ThinkingConfigEnabledParam(
|
||||
type="enabled", budget_tokens=thinking_budget
|
||||
)
|
||||
else:
|
||||
model_args["thinking"] = ThinkingConfigDisabledParam(type="disabled")
|
||||
model_args["temperature"] = options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
)
|
||||
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
) from err
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(chat_log, stream, messages),
|
||||
)
|
||||
if not isinstance(content, conversation.AssistantContent)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
break
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
from homeassistant.config_entries import SOURCE_SYSTEM
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, discovery_flow
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -37,7 +37,6 @@ from .manager import (
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
ManagerStateEvent,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
@@ -45,6 +44,7 @@ from .manager import (
|
||||
WrittenBackup,
|
||||
)
|
||||
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
||||
from .services import async_setup_services
|
||||
from .util import suggested_filename, suggested_filename_from_name_date
|
||||
from .websocket import async_register_websocket_handlers
|
||||
|
||||
@@ -71,12 +71,12 @@ __all__ = [
|
||||
"IncorrectPasswordError",
|
||||
"LocalBackupAgent",
|
||||
"ManagerBackup",
|
||||
"ManagerStateEvent",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
@@ -103,39 +103,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
backup_manager = BackupManager(hass, reader_writer)
|
||||
hass.data[DATA_MANAGER] = backup_manager
|
||||
try:
|
||||
await backup_manager.async_setup()
|
||||
except Exception as err:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_exception(err)
|
||||
raise
|
||||
else:
|
||||
hass.data[DATA_BACKUP].manager_ready.set_result(None)
|
||||
await backup_manager.async_setup()
|
||||
|
||||
async_register_websocket_handlers(hass, with_hassio)
|
||||
|
||||
async def async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[agent_id],
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_database=True,
|
||||
include_folders=None,
|
||||
include_homeassistant=True,
|
||||
name=None,
|
||||
password=None,
|
||||
)
|
||||
|
||||
async def async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating automatic backups."""
|
||||
await backup_manager.async_create_automatic_backup()
|
||||
|
||||
if not with_hassio:
|
||||
hass.services.async_register(DOMAIN, "create", async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", async_handle_create_automatic_service
|
||||
)
|
||||
async_setup_services(hass)
|
||||
|
||||
async_register_http_views(hass)
|
||||
|
||||
@@ -164,3 +136,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bo
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: BackupConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_manager(hass: HomeAssistant) -> BackupManager:
|
||||
"""Get the backup manager instance.
|
||||
|
||||
Raises HomeAssistantError if the backup integration is not available.
|
||||
"""
|
||||
if DATA_MANAGER not in hass.data:
|
||||
raise HomeAssistantError("Backup integration is not available")
|
||||
|
||||
return hass.data[DATA_MANAGER]
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"""Websocket commands for the Backup integration."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import async_subscribe_events
|
||||
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import ManagerStateEvent
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_websocket_handlers(hass: HomeAssistant) -> None:
|
||||
"""Register websocket commands."""
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
if DATA_MANAGER in hass.data:
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = async_subscribe_events(hass, on_event)
|
||||
connection.send_result(msg["id"])
|
||||
@@ -8,10 +8,6 @@ from datetime import datetime
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.backup import (
|
||||
async_subscribe_events,
|
||||
async_subscribe_platform_events,
|
||||
)
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -56,8 +52,8 @@ class BackupDataUpdateCoordinator(DataUpdateCoordinator[BackupCoordinatorData]):
|
||||
update_interval=None,
|
||||
)
|
||||
self.unsubscribe: list[Callable[[], None]] = [
|
||||
async_subscribe_events(hass, self._on_event),
|
||||
async_subscribe_platform_events(hass, self._on_event),
|
||||
backup_manager.async_subscribe_events(self._on_event),
|
||||
backup_manager.async_subscribe_platform_events(self._on_event),
|
||||
]
|
||||
|
||||
self.backup_manager = backup_manager
|
||||
|
||||
@@ -36,7 +36,6 @@ from homeassistant.helpers import (
|
||||
issue_registry as ir,
|
||||
start,
|
||||
)
|
||||
from homeassistant.helpers.backup import DATA_BACKUP
|
||||
from homeassistant.helpers.json import json_bytes
|
||||
from homeassistant.util import dt as dt_util, json as json_util
|
||||
|
||||
@@ -372,12 +371,10 @@ class BackupManager:
|
||||
# Latest backup event and backup event subscribers
|
||||
self.last_event: ManagerStateEvent = BlockedEvent()
|
||||
self.last_action_event: ManagerStateEvent | None = None
|
||||
self._backup_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_event_subscriptions
|
||||
self._backup_platform_event_subscriptions = hass.data[
|
||||
DATA_BACKUP
|
||||
].backup_platform_event_subscriptions
|
||||
self._backup_event_subscriptions: list[Callable[[ManagerStateEvent], None]] = []
|
||||
self._backup_platform_event_subscriptions: list[
|
||||
Callable[[BackupPlatformEvent], None]
|
||||
] = []
|
||||
|
||||
async def async_setup(self) -> None:
|
||||
"""Set up the backup manager."""
|
||||
@@ -1385,6 +1382,32 @@ class BackupManager:
|
||||
for subscription in self._backup_event_subscriptions:
|
||||
subscription(event)
|
||||
|
||||
@callback
|
||||
def async_subscribe_events(
|
||||
self,
|
||||
on_event: Callable[[ManagerStateEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
@callback
|
||||
def async_subscribe_platform_events(
|
||||
self,
|
||||
on_event: Callable[[BackupPlatformEvent], None],
|
||||
) -> Callable[[], None]:
|
||||
"""Subscribe to backup platform events."""
|
||||
|
||||
def remove_subscription() -> None:
|
||||
self._backup_platform_event_subscriptions.remove(on_event)
|
||||
|
||||
self._backup_platform_event_subscriptions.append(on_event)
|
||||
return remove_subscription
|
||||
|
||||
def _create_automatic_backup_failed_issue(
|
||||
self, translation_key: str, translation_placeholders: dict[str, str] | None
|
||||
) -> None:
|
||||
|
||||
@@ -19,9 +19,14 @@ from homeassistant.components.onboarding import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
|
||||
from . import BackupManager, Folder, IncorrectPasswordError, http as backup_http
|
||||
from . import (
|
||||
BackupManager,
|
||||
Folder,
|
||||
IncorrectPasswordError,
|
||||
async_get_manager,
|
||||
http as backup_http,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from homeassistant.components.onboarding import OnboardingStoreData
|
||||
@@ -54,7 +59,7 @@ def with_backup_manager[_ViewT: BaseOnboardingView, **_P](
|
||||
if self._data["done"]:
|
||||
raise HTTPUnauthorized
|
||||
|
||||
manager = await async_get_backup_manager(request.app[KEY_HASS])
|
||||
manager = async_get_manager(request.app[KEY_HASS])
|
||||
return await func(self, manager, request, *args, **kwargs)
|
||||
|
||||
return with_backup
|
||||
|
||||
36
homeassistant/components/backup/services.py
Normal file
36
homeassistant/components/backup/services.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""The Backup integration."""
|
||||
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .const import DATA_MANAGER, DOMAIN
|
||||
|
||||
|
||||
async def _async_handle_create_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating backups."""
|
||||
backup_manager = call.hass.data[DATA_MANAGER]
|
||||
agent_id = list(backup_manager.local_backup_agents)[0]
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[agent_id],
|
||||
include_addons=None,
|
||||
include_all_addons=False,
|
||||
include_database=True,
|
||||
include_folders=None,
|
||||
include_homeassistant=True,
|
||||
name=None,
|
||||
password=None,
|
||||
)
|
||||
|
||||
|
||||
async def _async_handle_create_automatic_service(call: ServiceCall) -> None:
|
||||
"""Service handler for creating automatic backups."""
|
||||
await call.hass.data[DATA_MANAGER].async_create_automatic_backup()
|
||||
|
||||
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register services."""
|
||||
if not is_hassio(hass):
|
||||
hass.services.async_register(DOMAIN, "create", _async_handle_create_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, "create_automatic", _async_handle_create_automatic_service
|
||||
)
|
||||
@@ -10,7 +10,11 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .config import Day, ScheduleRecurrence
|
||||
from .const import DATA_MANAGER, LOGGER
|
||||
from .manager import DecryptOnDowloadNotSupported, IncorrectPasswordError
|
||||
from .manager import (
|
||||
DecryptOnDowloadNotSupported,
|
||||
IncorrectPasswordError,
|
||||
ManagerStateEvent,
|
||||
)
|
||||
from .models import BackupNotFound, Folder
|
||||
|
||||
|
||||
@@ -30,6 +34,7 @@ def async_register_websocket_handlers(hass: HomeAssistant, with_hassio: bool) ->
|
||||
websocket_api.async_register_command(hass, handle_create_with_automatic_settings)
|
||||
websocket_api.async_register_command(hass, handle_delete)
|
||||
websocket_api.async_register_command(hass, handle_restore)
|
||||
websocket_api.async_register_command(hass, handle_subscribe_events)
|
||||
|
||||
websocket_api.async_register_command(hass, handle_config_info)
|
||||
websocket_api.async_register_command(hass, handle_config_update)
|
||||
@@ -417,3 +422,22 @@ def handle_config_update(
|
||||
changes.pop("type")
|
||||
manager.config.update(**changes)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "backup/subscribe_events"})
|
||||
@websocket_api.async_response
|
||||
async def handle_subscribe_events(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Subscribe to backup events."""
|
||||
|
||||
def on_event(event: ManagerStateEvent) -> None:
|
||||
connection.send_message(websocket_api.event_message(msg["id"], event))
|
||||
|
||||
manager = hass.data[DATA_MANAGER]
|
||||
on_event(manager.last_event)
|
||||
connection.subscriptions[msg["id"]] = manager.async_subscribe_events(on_event)
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"bleak-retry-connector==3.9.0",
|
||||
"bluetooth-adapters==0.21.4",
|
||||
"bluetooth-auto-recovery==1.5.2",
|
||||
"bluetooth-data-tools==1.28.2",
|
||||
"bluetooth-data-tools==1.28.1",
|
||||
"dbus-fast==2.43.0",
|
||||
"habluetooth==3.49.0"
|
||||
]
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_INSTALLER_CODE, CONF_USER_CODE, DOMAIN
|
||||
from .services import setup_services
|
||||
from .services import async_setup_services
|
||||
from .types import BoschAlarmConfigEntry
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
@@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up bosch alarm services."""
|
||||
setup_services(hass)
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.util import dt as dt_util
|
||||
@@ -66,7 +66,8 @@ async def async_set_panel_date(call: ServiceCall) -> None:
|
||||
) from err
|
||||
|
||||
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services for the bosch alarm integration."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==0.106.0"],
|
||||
"requirements": ["hass-nabucasa==0.104.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ from pycoolmasternet_async import CoolMasterNet
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .const import CONF_SWING_SUPPORT
|
||||
from .const import CONF_SWING_SUPPORT, DOMAIN
|
||||
from .coordinator import CoolmasterConfigEntry, CoolmasterDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.BUTTON, Platform.CLIMATE, Platform.SENSOR]
|
||||
@@ -48,3 +49,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: CoolmasterConfigEntry) -> bool:
|
||||
"""Unload a Coolmaster config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_remove_config_entry_device(
|
||||
hass: HomeAssistant,
|
||||
config_entry: CoolmasterConfigEntry,
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> bool:
|
||||
"""Remove a config entry from a device."""
|
||||
return not device_entry.identifiers.intersection(
|
||||
(DOMAIN, unit_id) for unit_id in config_entry.runtime_data.data
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ from devolo_home_control_api.devices.zwave import Zwave
|
||||
from devolo_home_control_api.homecontrol import HomeControl
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -35,7 +35,7 @@ class DevoloDeviceEntity(Entity):
|
||||
) # This is not doing I/O. It fetches an internal state of the API
|
||||
self._attr_should_poll = False
|
||||
self._attr_unique_id = element_uid
|
||||
self._attr_device_info = DeviceInfo(
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
configuration_url=f"https://{urlparse(device_instance.href).netloc}",
|
||||
identifiers={(DOMAIN, self._device_instance.uid)},
|
||||
manufacturer=device_instance.brand,
|
||||
@@ -88,6 +88,16 @@ class DevoloDeviceEntity(Entity):
|
||||
elif len(message) == 3 and message[2] == "status":
|
||||
# Maybe the API wants to tell us, that the device went on- or offline.
|
||||
self._attr_available = self._device_instance.is_online()
|
||||
elif message[1] == "del" and self.platform.config_entry:
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self._device_instance.uid)}
|
||||
)
|
||||
if device:
|
||||
device_registry.async_update_device(
|
||||
device.id,
|
||||
remove_config_entry_id=self.platform.config_entry.entry_id,
|
||||
)
|
||||
else:
|
||||
_LOGGER.debug("No valid message received: %s", message)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.45.0", "getmac==0.9.5"],
|
||||
"requirements": ["async-upnp-client==0.44.0", "getmac==0.9.5"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"dependencies": ["ssdp"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["async-upnp-client==0.45.0"],
|
||||
"requirements": ["async-upnp-client==0.44.0"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -12,7 +12,7 @@ from .bridge import DynaliteBridge
|
||||
from .const import DOMAIN, LOGGER, PLATFORMS
|
||||
from .convert_config import convert_config
|
||||
from .panel import async_register_dynalite_frontend
|
||||
from .services import setup_services
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -21,7 +21,7 @@ type DynaliteConfigEntry = ConfigEntry[DynaliteBridge]
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Dynalite platform."""
|
||||
setup_services(hass)
|
||||
async_setup_services(hass)
|
||||
|
||||
await async_register_dynalite_frontend(hass)
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ async def _request_channel_level(service_call: ServiceCall) -> None:
|
||||
|
||||
|
||||
@callback
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Dynalite platform."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.5.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==13.4.0"]
|
||||
}
|
||||
|
||||
@@ -16,7 +16,12 @@ from homeassistant.config_entries import (
|
||||
from homeassistant.const import CONF_API_KEY, CONF_URL
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import selector
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
selector,
|
||||
)
|
||||
|
||||
from .const import (
|
||||
CONF_MESSAGE,
|
||||
@@ -26,6 +31,9 @@ from .const import (
|
||||
FEED_ID,
|
||||
FEED_NAME,
|
||||
FEED_TAG,
|
||||
SYNC_MODE,
|
||||
SYNC_MODE_AUTO,
|
||||
SYNC_MODE_MANUAL,
|
||||
)
|
||||
|
||||
|
||||
@@ -102,6 +110,17 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"mode": "dropdown",
|
||||
"multiple": True,
|
||||
}
|
||||
if user_input.get(SYNC_MODE) == SYNC_MODE_AUTO:
|
||||
return self.async_create_entry(
|
||||
title=sensor_name(self.url),
|
||||
data={
|
||||
CONF_URL: self.url,
|
||||
CONF_API_KEY: self.api_key,
|
||||
CONF_ONLY_INCLUDE_FEEDID: [
|
||||
feed[FEED_ID] for feed in result[CONF_MESSAGE]
|
||||
],
|
||||
},
|
||||
)
|
||||
return await self.async_step_choose_feeds()
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
@@ -110,6 +129,15 @@ class EmoncmsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
{
|
||||
vol.Required(CONF_URL): str,
|
||||
vol.Required(CONF_API_KEY): str,
|
||||
vol.Required(
|
||||
SYNC_MODE, default=SYNC_MODE_MANUAL
|
||||
): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[SYNC_MODE_MANUAL, SYNC_MODE_AUTO],
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key=SYNC_MODE,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
user_input,
|
||||
|
||||
@@ -14,6 +14,9 @@ EMONCMS_UUID_DOC_URL = (
|
||||
FEED_ID = "id"
|
||||
FEED_NAME = "name"
|
||||
FEED_TAG = "tag"
|
||||
SYNC_MODE = "sync_mode"
|
||||
SYNC_MODE_AUTO = "auto"
|
||||
SYNC_MODE_MANUAL = "manual"
|
||||
|
||||
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"sync_mode": "Synchronization mode"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "Server URL starting with the protocol (http or https)",
|
||||
@@ -24,6 +25,14 @@
|
||||
"already_configured": "This server is already configured"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"sync_mode": {
|
||||
"options": {
|
||||
"auto": "Synchronize all available Feeds",
|
||||
"manual": "Select which Feeds to synchronize"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"energy": {
|
||||
|
||||
@@ -63,7 +63,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: EnphaseConfigEntry) ->
|
||||
coordinator = entry.runtime_data
|
||||
coordinator.async_cancel_token_refresh()
|
||||
coordinator.async_cancel_firmware_refresh()
|
||||
coordinator.async_cancel_mac_verification()
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyenphase"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyenphase==2.2.2"],
|
||||
"requirements": ["pyenphase==2.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_enphase-envoy._tcp.local."
|
||||
|
||||
@@ -363,7 +363,7 @@
|
||||
"discharging": "[%key:common::state::discharging%]",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"charging": "[%key:common::state::charging%]",
|
||||
"full": "Full"
|
||||
"full": "[%key:common::state::full%]"
|
||||
}
|
||||
},
|
||||
"acb_available_energy": {
|
||||
|
||||
@@ -281,7 +281,7 @@ class EsphomeEntity(EsphomeBaseEntity, Generic[_InfoT, _StateT]):
|
||||
|
||||
_static_info: _InfoT
|
||||
_state: _StateT
|
||||
_has_state: bool = False
|
||||
_has_state: bool
|
||||
unique_id: str
|
||||
|
||||
def __init__(
|
||||
|
||||
@@ -19,7 +19,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = (
|
||||
SensorEntityDescription(
|
||||
key="chlorine",
|
||||
translation_key="chlorine",
|
||||
native_unit_of_measurement=UnitOfElectricPotential.MILLIVOLT,
|
||||
native_unit_of_measurement="mg/L",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
|
||||
@@ -171,19 +171,14 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat
|
||||
|
||||
for device in new_data.devices.values():
|
||||
# create device registry entry for new main devices
|
||||
if device.ain not in self.data.devices and (
|
||||
device.device_and_unit_id[1] is None
|
||||
or (
|
||||
# workaround for sub units without a main device, e.g. Energy 250
|
||||
# https://github.com/home-assistant/core/issues/145204
|
||||
device.device_and_unit_id[1] == "1"
|
||||
and device.device_and_unit_id[0] not in new_data.devices
|
||||
)
|
||||
if (
|
||||
device.ain not in self.data.devices
|
||||
and device.device_and_unit_id[1] is None
|
||||
):
|
||||
dr.async_get(self.hass).async_get_or_create(
|
||||
config_entry_id=self.config_entry.entry_id,
|
||||
name=device.name,
|
||||
identifiers={(DOMAIN, device.device_and_unit_id[0])},
|
||||
identifiers={(DOMAIN, device.ain)},
|
||||
manufacturer=device.manufacturer,
|
||||
model=device.productname,
|
||||
sw_version=device.fw_version,
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250702.3"]
|
||||
"requirements": ["home-assistant-frontend==20250627.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["dacite", "gios"],
|
||||
"requirements": ["gios==6.1.2"]
|
||||
"requirements": ["gios==6.0.0"]
|
||||
}
|
||||
|
||||
@@ -306,11 +306,6 @@ class WebRTCProvider(CameraWebRTCProvider):
|
||||
await self.teardown()
|
||||
raise HomeAssistantError("Camera has no stream source")
|
||||
|
||||
if camera.platform.platform_name == "generic":
|
||||
# This is a workaround to use ffmpeg for generic cameras
|
||||
# A proper fix will be added in the future together with supporting multiple streams per camera
|
||||
stream_source = "ffmpeg:" + stream_source
|
||||
|
||||
if not self.async_is_supported(stream_source):
|
||||
await self.teardown()
|
||||
raise HomeAssistantError("Stream source is not supported by go2rtc")
|
||||
|
||||
@@ -127,7 +127,7 @@ class GoogleCloudSpeechToTextEntity(SpeechToTextEntity):
|
||||
try:
|
||||
responses = await self._client.streaming_recognize(
|
||||
requests=request_generator(),
|
||||
timeout=30,
|
||||
timeout=10,
|
||||
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
||||
)
|
||||
|
||||
|
||||
@@ -218,7 +218,7 @@ class BaseGoogleCloudProvider:
|
||||
|
||||
response = await self._client.synthesize_speech(
|
||||
request,
|
||||
timeout=30,
|
||||
timeout=10,
|
||||
retry=AsyncRetry(initial=0.1, maximum=2.0, multiplier=2.0),
|
||||
)
|
||||
|
||||
|
||||
@@ -330,14 +330,13 @@ async def google_generative_ai_config_option_schema(
|
||||
api_models = [api_model async for api_model in api_models_pager]
|
||||
models = [
|
||||
SelectOptionDict(
|
||||
label=api_model.name.lstrip("models/"),
|
||||
label=api_model.display_name,
|
||||
value=api_model.name,
|
||||
)
|
||||
for api_model in sorted(
|
||||
api_models, key=lambda x: x.name.lstrip("models/") or ""
|
||||
)
|
||||
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
|
||||
if (
|
||||
api_model.name
|
||||
api_model.display_name
|
||||
and api_model.name
|
||||
and ("tts" in api_model.name) == (subentry_type == "tts")
|
||||
and "vision" not in api_model.name
|
||||
and api_model.supported_actions
|
||||
|
||||
@@ -27,7 +27,7 @@ from .const import (
|
||||
SIGNAL_PAIRED_SENSOR_COORDINATOR_ADDED,
|
||||
)
|
||||
from .coordinator import GuardianDataUpdateCoordinator
|
||||
from .services import setup_services
|
||||
from .services import async_setup_services
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -55,7 +55,7 @@ class GuardianData:
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Elexa Guardian component."""
|
||||
setup_services(hass)
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -122,8 +122,9 @@ async def async_upgrade_firmware(call: ServiceCall, data: GuardianData) -> None:
|
||||
)
|
||||
|
||||
|
||||
def setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the Renault services."""
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register the guardian services."""
|
||||
for service_name, schema, method in (
|
||||
(
|
||||
SERVICE_NAME_PAIR_SENSOR,
|
||||
|
||||
@@ -48,13 +48,13 @@ from homeassistant.components.backup import (
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
async_get_manager as async_get_backup_manager,
|
||||
suggested_filename as suggested_backup_filename,
|
||||
suggested_filename_from_name_date,
|
||||
)
|
||||
from homeassistant.const import __version__ as HAVERSION
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.backup import async_get_manager as async_get_backup_manager
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
@@ -839,7 +839,7 @@ async def backup_addon_before_update(
|
||||
|
||||
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
||||
"""Prepare for updating core."""
|
||||
backup_manager = await async_get_backup_manager(hass)
|
||||
backup_manager = async_get_backup_manager(hass)
|
||||
client = get_supervisor_client(hass)
|
||||
|
||||
try:
|
||||
|
||||
@@ -11,7 +11,6 @@ from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
from aiohttp import ClientTimeout, ClientWebSocketResponse, hdrs, web
|
||||
from aiohttp.helpers import must_be_empty_body
|
||||
from aiohttp.web_exceptions import HTTPBadGateway, HTTPBadRequest
|
||||
from multidict import CIMultiDict
|
||||
from yarl import URL
|
||||
@@ -185,16 +184,13 @@ class HassIOIngress(HomeAssistantView):
|
||||
content_type = "application/octet-stream"
|
||||
|
||||
# Simple request
|
||||
if (empty_body := must_be_empty_body(result.method, result.status)) or (
|
||||
if result.status in (204, 304) or (
|
||||
content_length is not UNDEFINED
|
||||
and (content_length_int := int(content_length))
|
||||
<= MAX_SIMPLE_RESPONSE_SIZE
|
||||
):
|
||||
# Return Response
|
||||
if empty_body:
|
||||
body = None
|
||||
else:
|
||||
body = await result.read()
|
||||
body = await result.read()
|
||||
simple_response = web.Response(
|
||||
headers=headers,
|
||||
status=result.status,
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
"""The hddtemp component."""
|
||||
|
||||
DOMAIN = "hddtemp"
|
||||
|
||||
@@ -22,11 +22,14 @@ from homeassistant.const import (
|
||||
CONF_PORT,
|
||||
UnitOfTemperature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, create_issue
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_DEVICE = "device"
|
||||
@@ -56,6 +59,21 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the HDDTemp sensor."""
|
||||
create_issue(
|
||||
hass,
|
||||
HOMEASSISTANT_DOMAIN,
|
||||
f"deprecated_system_packages_yaml_integration_{DOMAIN}",
|
||||
breaks_in_ha_version="2025.12.0",
|
||||
is_fixable=False,
|
||||
issue_domain=DOMAIN,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_system_packages_yaml_integration",
|
||||
translation_placeholders={
|
||||
"domain": DOMAIN,
|
||||
"integration_title": "hddtemp",
|
||||
},
|
||||
)
|
||||
|
||||
name = config.get(CONF_NAME)
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
|
||||
@@ -9,9 +9,9 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import services
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HeosConfigEntry, HeosCoordinator
|
||||
from .services import async_setup_services
|
||||
|
||||
PLATFORMS = [Platform.MEDIA_PLAYER]
|
||||
|
||||
@@ -22,7 +22,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the HEOS component."""
|
||||
services.register(hass)
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import ATTR_MEDIA_VOLUME_LEVEL
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, SupportsResponse, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
@@ -44,7 +44,8 @@ HEOS_SIGN_IN_SCHEMA = vol.Schema(
|
||||
HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
|
||||
|
||||
|
||||
def register(hass: HomeAssistant) -> None:
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register HEOS services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import DOMAIN, OLD_NEW_UNIQUE_ID_SUFFIX_MAP
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
|
||||
from .services import register_actions
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -43,7 +43,7 @@ PLATFORMS = [
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Home Connect component."""
|
||||
register_actions(hass)
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -41,12 +41,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import (
|
||||
API_DEFAULT_RETRY_AFTER,
|
||||
APPLIANCES_WITH_PROGRAMS,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .const import API_DEFAULT_RETRY_AFTER, APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -71,7 +66,6 @@ class HomeConnectApplianceData:
|
||||
|
||||
def update(self, other: HomeConnectApplianceData) -> None:
|
||||
"""Update data with data from other instance."""
|
||||
self.commands.clear()
|
||||
self.commands.update(other.commands)
|
||||
self.events.update(other.events)
|
||||
self.info.connected = other.info.connected
|
||||
@@ -207,28 +201,6 @@ class HomeConnectCoordinator(
|
||||
raw_key=status_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
if (
|
||||
status_key == StatusKey.BSH_COMMON_OPERATION_STATE
|
||||
and event.value == BSH_OPERATION_STATE_PAUSE
|
||||
and CommandKey.BSH_COMMON_RESUME_PROGRAM
|
||||
not in (
|
||||
commands := self.data[
|
||||
event_message_ha_id
|
||||
].commands
|
||||
)
|
||||
):
|
||||
# All the appliances that can be paused
|
||||
# should have the resume command available.
|
||||
commands.add(CommandKey.BSH_COMMON_RESUME_PROGRAM)
|
||||
for (
|
||||
listener,
|
||||
context,
|
||||
) in self._special_listeners.values():
|
||||
if (
|
||||
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
|
||||
not in context
|
||||
):
|
||||
listener()
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.NOTIFY:
|
||||
@@ -655,7 +627,10 @@ class HomeConnectCoordinator(
|
||||
"times": str(MAX_EXECUTIONS),
|
||||
"time_window": str(MAX_EXECUTIONS_TIME_WINDOW // 60),
|
||||
"home_connect_resource_url": "https://www.home-connect.com/global/help-support/error-codes#/Togglebox=15362315-13320636-1/",
|
||||
"home_assistant_core_issue_url": "https://github.com/home-assistant/core/issues/147299",
|
||||
"home_assistant_core_new_issue_url": (
|
||||
"https://github.com/home-assistant/core/issues/new?template=bug_report.yml"
|
||||
f"&integration_name={DOMAIN}&integration_link=https://www.home-assistant.io/integrations/{DOMAIN}/"
|
||||
),
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -18,7 +18,7 @@ from aiohomeconnect.model.error import HomeConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import ATTR_DEVICE_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
@@ -522,7 +522,8 @@ async def async_service_start_program(call: ServiceCall) -> None:
|
||||
await _async_service_program(call, True)
|
||||
|
||||
|
||||
def register_actions(hass: HomeAssistant) -> None:
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register custom actions."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
"step": {
|
||||
"confirm": {
|
||||
"title": "[%key:component::home_connect::issues::home_connect_too_many_connected_paired_events::title%]",
|
||||
"description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please see the following issue in the [Home Assistant core repository]({home_assistant_core_issue_url})."
|
||||
"description": "The appliance \"{appliance_name}\" has been reported as connected or paired {times} times in less than {time_window} minutes, so refreshes on connected or paired events has been disabled to avoid exceeding the API rate limit.\n\nPlease refer to the [Home Connect Wi-Fi requirements and recommendations]({home_connect_resource_url}). If everything seems right with your network configuration, restart the appliance.\n\nClick \"submit\" to re-enable the updates.\nIf the issue persists, please create an issue in the [Home Assistant core repository]({home_assistant_core_new_issue_url})."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,9 @@ class HomematicipHAP:
|
||||
|
||||
self._ws_close_requested = False
|
||||
self._ws_connection_closed = asyncio.Event()
|
||||
self._get_state_task: asyncio.Task | None = None
|
||||
self._retry_task: asyncio.Task | None = None
|
||||
self._tries = 0
|
||||
self._accesspoint_connected = True
|
||||
self.hmip_device_by_entity_id: dict[str, Any] = {}
|
||||
self.reset_connection_listener: Callable | None = None
|
||||
|
||||
@@ -159,8 +161,17 @@ class HomematicipHAP:
|
||||
"""
|
||||
if not self.home.connected:
|
||||
_LOGGER.error("HMIP access point has lost connection with the cloud")
|
||||
self._ws_connection_closed.set()
|
||||
self._accesspoint_connected = False
|
||||
self.set_all_to_unavailable()
|
||||
elif not self._accesspoint_connected:
|
||||
# Now the HOME_CHANGED event has fired indicating the access
|
||||
# point has reconnected to the cloud again.
|
||||
# Explicitly getting an update as entity states might have
|
||||
# changed during access point disconnect."""
|
||||
|
||||
job = self.hass.async_create_task(self.get_state())
|
||||
job.add_done_callback(self.get_state_finished)
|
||||
self._accesspoint_connected = True
|
||||
|
||||
@callback
|
||||
def async_create_entity(self, *args, **kwargs) -> None:
|
||||
@@ -174,43 +185,20 @@ class HomematicipHAP:
|
||||
await asyncio.sleep(30)
|
||||
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
|
||||
|
||||
async def _try_get_state(self) -> None:
|
||||
"""Call get_state in a loop until no error occurs, using exponential backoff on error."""
|
||||
|
||||
# Wait until WebSocket connection is established.
|
||||
while not self.home.websocket_is_connected():
|
||||
await asyncio.sleep(2)
|
||||
|
||||
delay = 8
|
||||
max_delay = 1500
|
||||
while True:
|
||||
try:
|
||||
await self.get_state()
|
||||
break
|
||||
except HmipConnectionError as err:
|
||||
_LOGGER.warning(
|
||||
"Get_state failed, retrying in %s seconds: %s", delay, err
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, max_delay)
|
||||
|
||||
async def get_state(self) -> None:
|
||||
"""Update HMIP state and tell Home Assistant."""
|
||||
await self.home.get_current_state_async()
|
||||
self.update_all()
|
||||
|
||||
def get_state_finished(self, future) -> None:
|
||||
"""Execute when try_get_state coroutine has finished."""
|
||||
"""Execute when get_state coroutine has finished."""
|
||||
try:
|
||||
future.result()
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Error updating state after HMIP access point reconnect: %s", err
|
||||
)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Updating state after HMIP access point reconnect finished successfully",
|
||||
)
|
||||
except HmipConnectionError:
|
||||
# Somehow connection could not recover. Will disconnect and
|
||||
# so reconnect loop is taking over.
|
||||
_LOGGER.error("Updating state after HMIP access point reconnect failed")
|
||||
self.hass.async_create_task(self.home.disable_events())
|
||||
|
||||
def set_all_to_unavailable(self) -> None:
|
||||
"""Set all devices to unavailable and tell Home Assistant."""
|
||||
@@ -234,8 +222,8 @@ class HomematicipHAP:
|
||||
async def async_reset(self) -> bool:
|
||||
"""Close the websocket connection."""
|
||||
self._ws_close_requested = True
|
||||
if self._get_state_task is not None:
|
||||
self._get_state_task.cancel()
|
||||
if self._retry_task is not None:
|
||||
self._retry_task.cancel()
|
||||
await self.home.disable_events_async()
|
||||
_LOGGER.debug("Closed connection to HomematicIP cloud server")
|
||||
await self.hass.config_entries.async_unload_platforms(
|
||||
@@ -259,9 +247,7 @@ class HomematicipHAP:
|
||||
"""Handle websocket connected."""
|
||||
_LOGGER.info("Websocket connection to HomematicIP Cloud established")
|
||||
if self._ws_connection_closed.is_set():
|
||||
self._get_state_task = self.hass.async_create_task(self._try_get_state())
|
||||
self._get_state_task.add_done_callback(self.get_state_finished)
|
||||
|
||||
await self.get_state()
|
||||
self._ws_connection_closed.clear()
|
||||
|
||||
async def ws_disconnected_handler(self) -> None:
|
||||
@@ -270,12 +256,11 @@ class HomematicipHAP:
|
||||
self._ws_connection_closed.set()
|
||||
|
||||
async def ws_reconnected_handler(self, reason: str) -> None:
|
||||
"""Handle websocket reconnection. Is called when Websocket tries to reconnect."""
|
||||
"""Handle websocket reconnection."""
|
||||
_LOGGER.info(
|
||||
"Websocket connection to HomematicIP Cloud trying to reconnect due to reason: %s",
|
||||
"Websocket connection to HomematicIP Cloud re-established due to reason: %s",
|
||||
reason,
|
||||
)
|
||||
|
||||
self._ws_connection_closed.set()
|
||||
|
||||
async def get_hap(
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/homematicip_cloud",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homematicip"],
|
||||
"requirements": ["homematicip==2.0.7"]
|
||||
"requirements": ["homematicip==2.0.6"]
|
||||
}
|
||||
|
||||
@@ -2,15 +2,18 @@
|
||||
|
||||
from datetime import datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from aioautomower.model import make_name_string
|
||||
|
||||
from homeassistant.components.calendar import CalendarEntity, CalendarEvent
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import AutomowerConfigEntry
|
||||
from .const import DOMAIN
|
||||
from .coordinator import AutomowerDataUpdateCoordinator
|
||||
from .entity import AutomowerBaseEntity
|
||||
|
||||
@@ -51,6 +54,19 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
|
||||
self._attr_unique_id = mower_id
|
||||
self._event: CalendarEvent | None = None
|
||||
|
||||
@property
|
||||
def device_name(self) -> str:
|
||||
"""Return the prefix for the event summary."""
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_entry = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, self.mower_id)}
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
assert device_entry is not None
|
||||
assert device_entry.name is not None
|
||||
|
||||
return device_entry.name_by_user or device_entry.name
|
||||
|
||||
@property
|
||||
def event(self) -> CalendarEvent | None:
|
||||
"""Return the current or next upcoming event."""
|
||||
@@ -66,7 +82,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
|
||||
program_event.work_area_id
|
||||
]
|
||||
return CalendarEvent(
|
||||
summary=make_name_string(work_area_name, program_event.schedule_no),
|
||||
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
|
||||
start=program_event.start,
|
||||
end=program_event.end,
|
||||
rrule=program_event.rrule_str,
|
||||
@@ -93,7 +109,7 @@ class AutomowerCalendarEntity(AutomowerBaseEntity, CalendarEntity):
|
||||
]
|
||||
calendar_events.append(
|
||||
CalendarEvent(
|
||||
summary=make_name_string(work_area_name, program_event.schedule_no),
|
||||
summary=f"{self.device_name} {make_name_string(work_area_name, program_event.schedule_no)}",
|
||||
start=program_event.start.replace(tzinfo=start_date.tzinfo),
|
||||
end=program_event.end.replace(tzinfo=start_date.tzinfo),
|
||||
rrule=program_event.rrule_str,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aioautomower"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioautomower==2025.6.0"]
|
||||
"requirements": ["aioautomower==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aioimmich==0.10.2"]
|
||||
"requirements": ["aioimmich==0.10.1"]
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class ImmichUpdateEntity(ImmichEntity, UpdateEntity):
|
||||
return self.coordinator.data.server_about.version
|
||||
|
||||
@property
|
||||
def latest_version(self) -> str | None:
|
||||
def latest_version(self) -> str:
|
||||
"""Available new immich server version."""
|
||||
assert self.coordinator.data.server_version_check
|
||||
return self.coordinator.data.server_version_check.release_version
|
||||
|
||||
@@ -10,4 +10,8 @@ OHM = "Ω"
|
||||
DISCOVERY_SVC_UUID = "9eae1000-9d0d-48c5-aa55-33e27f9bc533"
|
||||
|
||||
MAX_TEMP: int = 450
|
||||
MAX_TEMP_F: int = 850
|
||||
MIN_TEMP: int = 10
|
||||
MIN_TEMP_F: int = 50
|
||||
MIN_BOOST_TEMP: int = 250
|
||||
MIN_BOOST_TEMP_F: int = 480
|
||||
|
||||
@@ -168,7 +168,9 @@ class IronOSSettingsCoordinator(IronOSBaseCoordinator[SettingsDataResponse]):
|
||||
|
||||
if self.device.is_connected and characteristics:
|
||||
try:
|
||||
return await self.device.get_settings(list(characteristics))
|
||||
return await self.device.get_settings(
|
||||
list(characteristics | {CharSetting.TEMP_UNIT})
|
||||
)
|
||||
except CommunicationError as e:
|
||||
_LOGGER.debug("Failed to fetch settings", exc_info=e)
|
||||
|
||||
|
||||
@@ -6,10 +6,9 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse
|
||||
from pynecil import CharSetting, LiveDataResponse, SettingsDataResponse, TempUnit
|
||||
|
||||
from homeassistant.components.number import (
|
||||
DEFAULT_MAX_VALUE,
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
@@ -24,9 +23,17 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.unit_conversion import TemperatureConverter
|
||||
|
||||
from . import IronOSConfigEntry
|
||||
from .const import MAX_TEMP, MIN_TEMP
|
||||
from .const import (
|
||||
MAX_TEMP,
|
||||
MAX_TEMP_F,
|
||||
MIN_BOOST_TEMP,
|
||||
MIN_BOOST_TEMP_F,
|
||||
MIN_TEMP,
|
||||
MIN_TEMP_F,
|
||||
)
|
||||
from .coordinator import IronOSCoordinators
|
||||
from .entity import IronOSBaseEntity
|
||||
|
||||
@@ -38,9 +45,10 @@ class IronOSNumberEntityDescription(NumberEntityDescription):
|
||||
"""Describes IronOS number entity."""
|
||||
|
||||
value_fn: Callable[[LiveDataResponse, SettingsDataResponse], float | int | None]
|
||||
max_value_fn: Callable[[LiveDataResponse], float | int] | None = None
|
||||
characteristic: CharSetting
|
||||
raw_value_fn: Callable[[float], float | int] | None = None
|
||||
native_max_value_f: float | None = None
|
||||
native_min_value_f: float | None = None
|
||||
|
||||
|
||||
class PinecilNumber(StrEnum):
|
||||
@@ -74,44 +82,6 @@ def multiply(value: float | None, multiplier: float) -> float | None:
|
||||
|
||||
|
||||
PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SETPOINT_TEMP,
|
||||
translation_key=PinecilNumber.SETPOINT_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda data, _: data.setpoint_temp,
|
||||
characteristic=CharSetting.SETPOINT_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_step=5,
|
||||
max_value_fn=lambda data: min(data.max_tip_temp_ability or MAX_TEMP, MAX_TEMP),
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SLEEP_TEMP,
|
||||
translation_key=PinecilNumber.SLEEP_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda _, settings: settings.get("sleep_temp"),
|
||||
characteristic=CharSetting.SLEEP_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.BOOST_TEMP,
|
||||
translation_key=PinecilNumber.BOOST_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
value_fn=lambda _, settings: settings.get("boost_temp"),
|
||||
characteristic=CharSetting.BOOST_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=0,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.QC_MAX_VOLTAGE,
|
||||
translation_key=PinecilNumber.QC_MAX_VOLTAGE,
|
||||
@@ -296,32 +266,6 @@ PINECIL_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_short")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_SHORT,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=1,
|
||||
native_max_value=50,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_long")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_LONG,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=5,
|
||||
native_max_value=90,
|
||||
native_step=5,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
)
|
||||
|
||||
PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
@@ -341,6 +285,82 @@ PINECIL_NUMBER_DESCRIPTIONS_V223: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
"""
|
||||
The `device_class` attribute was removed from the `setpoint_temperature`, `sleep_temperature`, and `boost_temp` entities.
|
||||
These entities represent user-defined input values, not measured temperatures, and their
|
||||
interpretation depends on the device's current unit configuration. Applying a device_class
|
||||
results in automatic unit conversions, which introduce rounding errors due to the use of integers.
|
||||
This can prevent the correct value from being set, as the input is modified during synchronization with the device.
|
||||
"""
|
||||
PINECIL_TEMP_NUMBER_DESCRIPTIONS: tuple[IronOSNumberEntityDescription, ...] = (
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SLEEP_TEMP,
|
||||
translation_key=PinecilNumber.SLEEP_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda _, settings: settings.get("sleep_temp"),
|
||||
characteristic=CharSetting.SLEEP_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_min_value_f=MIN_TEMP_F,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.BOOST_TEMP,
|
||||
translation_key=PinecilNumber.BOOST_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda _, settings: settings.get("boost_temp"),
|
||||
characteristic=CharSetting.BOOST_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_BOOST_TEMP,
|
||||
native_min_value_f=MIN_BOOST_TEMP_F,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=10,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_SHORT,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_short")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_SHORT,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=1,
|
||||
native_max_value=50,
|
||||
native_step=1,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
translation_key=PinecilNumber.TEMP_INCREMENT_LONG,
|
||||
value_fn=(lambda _, settings: settings.get("temp_increment_long")),
|
||||
characteristic=CharSetting.TEMP_INCREMENT_LONG,
|
||||
raw_value_fn=lambda value: value,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=5,
|
||||
native_max_value=90,
|
||||
native_step=5,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
)
|
||||
|
||||
PINECIL_SETPOINT_NUMBER_DESCRIPTION = IronOSNumberEntityDescription(
|
||||
key=PinecilNumber.SETPOINT_TEMP,
|
||||
translation_key=PinecilNumber.SETPOINT_TEMP,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
value_fn=lambda data, _: data.setpoint_temp,
|
||||
characteristic=CharSetting.SETPOINT_TEMP,
|
||||
mode=NumberMode.BOX,
|
||||
native_min_value=MIN_TEMP,
|
||||
native_max_value=MAX_TEMP,
|
||||
native_min_value_f=MIN_TEMP_F,
|
||||
native_max_value_f=MAX_TEMP_F,
|
||||
native_step=5,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -354,9 +374,18 @@ async def async_setup_entry(
|
||||
if coordinators.live_data.v223_features:
|
||||
descriptions += PINECIL_NUMBER_DESCRIPTIONS_V223
|
||||
|
||||
async_add_entities(
|
||||
entities = [
|
||||
IronOSNumberEntity(coordinators, description) for description in descriptions
|
||||
]
|
||||
|
||||
entities.extend(
|
||||
IronOSTemperatureNumberEntity(coordinators, description)
|
||||
for description in PINECIL_TEMP_NUMBER_DESCRIPTIONS
|
||||
)
|
||||
entities.append(
|
||||
IronOSSetpointNumberEntity(coordinators, PINECIL_SETPOINT_NUMBER_DESCRIPTION)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
|
||||
@@ -388,15 +417,6 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
|
||||
self.coordinator.data, self.settings.data
|
||||
)
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return sensor state."""
|
||||
|
||||
if self.entity_description.max_value_fn is not None:
|
||||
return self.entity_description.max_value_fn(self.coordinator.data)
|
||||
|
||||
return self.entity_description.native_max_value or DEFAULT_MAX_VALUE
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
|
||||
@@ -407,3 +427,60 @@ class IronOSNumberEntity(IronOSBaseEntity, NumberEntity):
|
||||
)
|
||||
)
|
||||
await self.settings.async_request_refresh()
|
||||
|
||||
|
||||
class IronOSTemperatureNumberEntity(IronOSNumberEntity):
|
||||
"""Implementation of a IronOS temperature number entity."""
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement of the sensor, if any."""
|
||||
|
||||
return (
|
||||
UnitOfTemperature.FAHRENHEIT
|
||||
if self.settings.data.get("temp_unit") is TempUnit.FAHRENHEIT
|
||||
else UnitOfTemperature.CELSIUS
|
||||
)
|
||||
|
||||
@property
|
||||
def native_min_value(self) -> float:
|
||||
"""Return the minimum value."""
|
||||
|
||||
return (
|
||||
self.entity_description.native_min_value_f
|
||||
if self.entity_description.native_min_value_f
|
||||
and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT
|
||||
else super().native_min_value
|
||||
)
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
|
||||
return (
|
||||
self.entity_description.native_max_value_f
|
||||
if self.entity_description.native_max_value_f
|
||||
and self.native_unit_of_measurement is UnitOfTemperature.FAHRENHEIT
|
||||
else super().native_max_value
|
||||
)
|
||||
|
||||
|
||||
class IronOSSetpointNumberEntity(IronOSTemperatureNumberEntity):
|
||||
"""IronOS setpoint temperature entity."""
|
||||
|
||||
@property
|
||||
def native_max_value(self) -> float:
|
||||
"""Return the maximum value."""
|
||||
|
||||
return (
|
||||
min(
|
||||
TemperatureConverter.convert(
|
||||
float(max_tip_c),
|
||||
UnitOfTemperature.CELSIUS,
|
||||
self.native_unit_of_measurement,
|
||||
),
|
||||
super().native_max_value,
|
||||
)
|
||||
if (max_tip_c := self.coordinator.data.max_tip_temp_ability) is not None
|
||||
else super().native_max_value
|
||||
)
|
||||
|
||||
@@ -91,7 +91,7 @@ from .schema import (
|
||||
TimeSchema,
|
||||
WeatherSchema,
|
||||
)
|
||||
from .services import register_knx_services
|
||||
from .services import async_setup_services
|
||||
from .storage.config_store import STORAGE_KEY as CONFIG_STORAGE_KEY, KNXConfigStore
|
||||
from .telegrams import STORAGE_KEY as TELEGRAMS_STORAGE_KEY, Telegrams
|
||||
from .websocket import register_panel
|
||||
@@ -138,7 +138,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if (conf := config.get(DOMAIN)) is not None:
|
||||
hass.data[_KNX_YAML_CONFIG] = dict(conf)
|
||||
|
||||
register_knx_services(hass)
|
||||
async_setup_services(hass)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def register_knx_services(hass: HomeAssistant) -> None:
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Register KNX integration services."""
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
|
||||
@@ -14,6 +14,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import CONF_SERVICE_CODE
|
||||
from .coordinator import PlenticoreConfigEntry, SettingDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -29,6 +30,7 @@ class PlenticoreSwitchEntityDescription(SwitchEntityDescription):
|
||||
on_label: str
|
||||
off_value: str
|
||||
off_label: str
|
||||
installer_required: bool = False
|
||||
|
||||
|
||||
SWITCH_SETTINGS_DATA = [
|
||||
@@ -42,6 +44,17 @@ SWITCH_SETTINGS_DATA = [
|
||||
off_value="2",
|
||||
off_label="Automatic economical",
|
||||
),
|
||||
PlenticoreSwitchEntityDescription(
|
||||
module_id="devices:local",
|
||||
key="Battery:ManualCharge",
|
||||
name="Battery Manual Charge",
|
||||
is_on="1",
|
||||
on_value="1",
|
||||
on_label="On",
|
||||
off_value="0",
|
||||
off_label="Off",
|
||||
installer_required=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -73,7 +86,13 @@ async def async_setup_entry(
|
||||
description.key,
|
||||
)
|
||||
continue
|
||||
|
||||
if entry.data.get(CONF_SERVICE_CODE) is None and description.installer_required:
|
||||
_LOGGER.debug(
|
||||
"Skipping installer required setting data %s/%s",
|
||||
description.module_id,
|
||||
description.key,
|
||||
)
|
||||
continue
|
||||
entities.append(
|
||||
PlenticoreDataSwitch(
|
||||
settings_data_update_coordinator,
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import CONF_USE_BLUETOOTH, DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -57,10 +57,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: LaMarzoccoConfigEntry) -
|
||||
assert entry.unique_id
|
||||
serial = entry.unique_id
|
||||
|
||||
client = async_get_clientsession(hass)
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=entry.data[CONF_USERNAME],
|
||||
password=entry.data[CONF_PASSWORD],
|
||||
client=async_create_clientsession(hass),
|
||||
client=client,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -66,7 +66,7 @@ ENTITIES: tuple[LaMarzoccoBinarySensorEntityDescription, ...] = (
|
||||
WidgetType.CM_BACK_FLUSH, BackFlush(status=BackFlushStatus.OFF)
|
||||
),
|
||||
).status
|
||||
in (BackFlushStatus.REQUESTED, BackFlushStatus.CLEANING)
|
||||
is BackFlushStatus.REQUESTED
|
||||
),
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
supported_fn=lambda coordinator: (
|
||||
|
||||
@@ -33,7 +33,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectOptionDict,
|
||||
SelectSelector,
|
||||
@@ -83,7 +83,7 @@ class LmConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
**user_input,
|
||||
}
|
||||
|
||||
self._client = async_create_clientsession(self.hass)
|
||||
self._client = async_get_clientsession(self.hass)
|
||||
cloud_client = LaMarzoccoCloudClient(
|
||||
username=data[CONF_USERNAME],
|
||||
password=data[CONF_PASSWORD],
|
||||
|
||||
@@ -37,5 +37,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylamarzocco"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pylamarzocco==2.0.11"]
|
||||
"requirements": ["pylamarzocco==2.0.9"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pypck"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pypck==0.8.9", "lcn-frontend==0.2.5"]
|
||||
"requirements": ["pypck==0.8.10", "lcn-frontend==0.2.5"]
|
||||
}
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ld2410_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bluetooth-data-tools==1.28.2", "ld2410-ble==0.1.1"]
|
||||
"requirements": ["bluetooth-data-tools==1.28.1", "ld2410-ble==0.1.1"]
|
||||
}
|
||||
|
||||
@@ -35,5 +35,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/led_ble",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["bluetooth-data-tools==1.28.2", "led-ble==1.1.7"]
|
||||
"requirements": ["bluetooth-data-tools==1.28.1", "led-ble==1.1.7"]
|
||||
}
|
||||
|
||||
@@ -780,10 +780,10 @@
|
||||
"battery_level": {
|
||||
"name": "Battery",
|
||||
"state": {
|
||||
"high": "Full",
|
||||
"high": "[%key:common::state::full%]",
|
||||
"mid": "[%key:common::state::medium%]",
|
||||
"low": "[%key:common::state::low%]",
|
||||
"warning": "Empty"
|
||||
"warning": "[%key:common::state::empty%]"
|
||||
}
|
||||
},
|
||||
"relative_to_start": {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
"motor_fault_short": "Motor shorted",
|
||||
"motor_ot_amps": "Motor overtorqued",
|
||||
"motor_disconnected": "Motor disconnected",
|
||||
"empty": "Empty"
|
||||
"empty": "[%key:common::state::empty%]"
|
||||
}
|
||||
},
|
||||
"last_seen": {
|
||||
|
||||
@@ -45,7 +45,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.json import JsonObjectType, load_json_object
|
||||
|
||||
from .const import ATTR_FORMAT, ATTR_IMAGES, CONF_ROOMS_REGEX, DOMAIN, FORMAT_HTML
|
||||
from .services import register_services
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,7 +128,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
config[CONF_COMMANDS],
|
||||
)
|
||||
|
||||
register_services(hass)
|
||||
async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from typing import TYPE_CHECKING
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
@@ -50,7 +50,8 @@ async def _handle_send_message(call: ServiceCall) -> None:
|
||||
await matrix_bot.handle_send_message(call)
|
||||
|
||||
|
||||
def register_services(hass: HomeAssistant) -> None:
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the Matrix bot component."""
|
||||
|
||||
hass.services.async_register(
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from chip.clusters.ClusterObjects import ClusterAttributeDescriptor, ClusterCommand
|
||||
from matter_server.common import custom_clusters
|
||||
|
||||
from homeassistant.components.number import (
|
||||
@@ -44,6 +47,23 @@ class MatterNumberEntityDescription(NumberEntityDescription, MatterEntityDescrip
|
||||
"""Describe Matter Number Input entities."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class MatterRangeNumberEntityDescription(
|
||||
NumberEntityDescription, MatterEntityDescription
|
||||
):
|
||||
"""Describe Matter Number Input entities with min and max values."""
|
||||
|
||||
ha_to_native_value: Callable[[Any], Any]
|
||||
|
||||
# attribute descriptors to get the min and max value
|
||||
min_attribute: type[ClusterAttributeDescriptor]
|
||||
max_attribute: type[ClusterAttributeDescriptor]
|
||||
|
||||
# command: a custom callback to create the command to send to the device
|
||||
# the callback's argument will be the index of the selected list value
|
||||
command: Callable[[int], ClusterCommand]
|
||||
|
||||
|
||||
class MatterNumber(MatterEntity, NumberEntity):
|
||||
"""Representation of a Matter Attribute as a Number entity."""
|
||||
|
||||
@@ -67,6 +87,42 @@ class MatterNumber(MatterEntity, NumberEntity):
|
||||
self._attr_native_value = value
|
||||
|
||||
|
||||
class MatterRangeNumber(MatterEntity, NumberEntity):
|
||||
"""Representation of a Matter Attribute as a Number entity with min and max values."""
|
||||
|
||||
entity_description: MatterRangeNumberEntityDescription
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Update the current value."""
|
||||
send_value = self.entity_description.ha_to_native_value(value)
|
||||
# custom command defined to set the new value
|
||||
await self.send_device_command(
|
||||
self.entity_description.command(send_value),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
|
||||
if value_convert := self.entity_description.measurement_to_ha:
|
||||
value = value_convert(value)
|
||||
self._attr_native_value = value
|
||||
self._attr_native_min_value = (
|
||||
cast(
|
||||
int,
|
||||
self.get_matter_attribute_value(self.entity_description.min_attribute),
|
||||
)
|
||||
/ 100
|
||||
)
|
||||
self._attr_native_max_value = (
|
||||
cast(
|
||||
int,
|
||||
self.get_matter_attribute_value(self.entity_description.max_attribute),
|
||||
)
|
||||
/ 100
|
||||
)
|
||||
|
||||
|
||||
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||
DISCOVERY_SCHEMAS = [
|
||||
MatterDiscoverySchema(
|
||||
@@ -213,4 +269,27 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_class=MatterNumber,
|
||||
required_attributes=(clusters.DoorLock.Attributes.AutoRelockTime,),
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.NUMBER,
|
||||
entity_description=MatterRangeNumberEntityDescription(
|
||||
key="TemperatureControlTemperatureSetpoint",
|
||||
name=None,
|
||||
translation_key="temperature_setpoint",
|
||||
command=lambda value: clusters.TemperatureControl.Commands.SetTemperature(
|
||||
targetTemperature=value
|
||||
),
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
measurement_to_ha=lambda x: None if x is None else x / 100,
|
||||
ha_to_native_value=lambda x: round(x * 100),
|
||||
min_attribute=clusters.TemperatureControl.Attributes.MinTemperature,
|
||||
max_attribute=clusters.TemperatureControl.Attributes.MaxTemperature,
|
||||
mode=NumberMode.SLIDER,
|
||||
),
|
||||
entity_class=MatterRangeNumber,
|
||||
required_attributes=(
|
||||
clusters.TemperatureControl.Attributes.TemperatureSetpoint,
|
||||
clusters.TemperatureControl.Attributes.MinTemperature,
|
||||
clusters.TemperatureControl.Attributes.MaxTemperature,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -183,6 +183,9 @@
|
||||
"temperature_offset": {
|
||||
"name": "Temperature offset"
|
||||
},
|
||||
"temperature_setpoint": {
|
||||
"name": "Temperature setpoint"
|
||||
},
|
||||
"pir_occupied_to_unoccupied_delay": {
|
||||
"name": "Occupied to unoccupied delay"
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ from homeassistant.components.vacuum import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import MatterEntity
|
||||
@@ -67,20 +68,31 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
entity_description: StateVacuumEntityDescription
|
||||
_platform_translation_key = "vacuum"
|
||||
|
||||
def _get_run_mode_by_tag(
|
||||
self, tag: ModeTag
|
||||
) -> clusters.RvcRunMode.Structs.ModeOptionStruct | None:
|
||||
"""Get the run mode by tag."""
|
||||
supported_run_modes = self._supported_run_modes or {}
|
||||
for mode in supported_run_modes.values():
|
||||
for t in mode.modeTags:
|
||||
if t.value == tag.value:
|
||||
return mode
|
||||
return None
|
||||
|
||||
async def async_stop(self, **kwargs: Any) -> None:
|
||||
"""Stop the vacuum cleaner."""
|
||||
# We simply set the RvcRunMode to the first runmode
|
||||
# that has the idle tag to stop the vacuum cleaner.
|
||||
# this is compatible with both Matter 1.2 and 1.3+ devices.
|
||||
supported_run_modes = self._supported_run_modes or {}
|
||||
for mode in supported_run_modes.values():
|
||||
for tag in mode.modeTags:
|
||||
if tag.value == ModeTag.IDLE:
|
||||
# stop the vacuum by changing the run mode to idle
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
return
|
||||
mode = self._get_run_mode_by_tag(ModeTag.IDLE)
|
||||
if mode is None:
|
||||
raise HomeAssistantError(
|
||||
"No supported run mode found to stop the vacuum cleaner."
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
|
||||
async def async_return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Set the vacuum cleaner to return to the dock."""
|
||||
@@ -110,14 +122,15 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
|
||||
# We simply set the RvcRunMode to the first runmode
|
||||
# that has the cleaning tag to start the vacuum cleaner.
|
||||
# this is compatible with both Matter 1.2 and 1.3+ devices.
|
||||
supported_run_modes = self._supported_run_modes or {}
|
||||
for mode in supported_run_modes.values():
|
||||
for tag in mode.modeTags:
|
||||
if tag.value == ModeTag.CLEANING:
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
return
|
||||
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
|
||||
if mode is None:
|
||||
raise HomeAssistantError(
|
||||
"No supported run mode found to start the vacuum cleaner."
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
|
||||
async def async_pause(self) -> None:
|
||||
"""Pause the cleaning task."""
|
||||
|
||||
@@ -6,8 +6,6 @@ import time
|
||||
from meteofrance_api.model.forecast import Forecast as MeteoFranceForecast
|
||||
|
||||
from homeassistant.components.weather import (
|
||||
ATTR_CONDITION_CLEAR_NIGHT,
|
||||
ATTR_CONDITION_SUNNY,
|
||||
ATTR_FORECAST_CONDITION,
|
||||
ATTR_FORECAST_HUMIDITY,
|
||||
ATTR_FORECAST_NATIVE_PRECIPITATION,
|
||||
@@ -51,13 +49,9 @@ from .const import (
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def format_condition(condition: str, force_day: bool = False) -> str:
|
||||
def format_condition(condition: str):
|
||||
"""Return condition from dict CONDITION_MAP."""
|
||||
mapped_condition = CONDITION_MAP.get(condition, condition)
|
||||
if force_day and mapped_condition == ATTR_CONDITION_CLEAR_NIGHT:
|
||||
# Meteo-France can return clear night condition instead of sunny for daily weather, so we map it to sunny
|
||||
return ATTR_CONDITION_SUNNY
|
||||
return mapped_condition
|
||||
return CONDITION_MAP.get(condition, condition)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -218,7 +212,7 @@ class MeteoFranceWeather(
|
||||
forecast["dt"]
|
||||
).isoformat(),
|
||||
ATTR_FORECAST_CONDITION: format_condition(
|
||||
forecast["weather12H"]["desc"], force_day=True
|
||||
forecast["weather12H"]["desc"]
|
||||
),
|
||||
ATTR_FORECAST_HUMIDITY: forecast["humidity"]["max"],
|
||||
ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"],
|
||||
|
||||
@@ -9,7 +9,6 @@ from datapoint.Forecast import Forecast
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
DOMAIN as SENSOR_DOMAIN,
|
||||
EntityCategory,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
SensorEntityDescription,
|
||||
@@ -60,7 +59,6 @@ SENSOR_TYPES: tuple[MetOfficeSensorEntityDescription, ...] = (
|
||||
native_attr_name="name",
|
||||
name="Station name",
|
||||
icon="mdi:label-outline",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
MetOfficeSensorEntityDescription(
|
||||
@@ -237,13 +235,14 @@ class MetOfficeCurrentSensor(
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
native_attr = self.entity_description.native_attr_name
|
||||
value = get_attribute(
|
||||
self.coordinator.data.now(), self.entity_description.native_attr_name
|
||||
)
|
||||
|
||||
if native_attr == "name":
|
||||
return str(self.coordinator.data.name)
|
||||
|
||||
value = get_attribute(self.coordinator.data.now(), native_attr)
|
||||
if native_attr == "significantWeatherCode" and value is not None:
|
||||
if (
|
||||
self.entity_description.native_attr_name == "significantWeatherCode"
|
||||
and value is not None
|
||||
):
|
||||
value = CONDITION_MAP.get(value)
|
||||
|
||||
return value
|
||||
|
||||
@@ -26,6 +26,14 @@ class OAuth2FlowHandler(
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
# "vg" is mandatory but the value doesn't seem to matter
|
||||
return {
|
||||
"vg": "sv-SE",
|
||||
}
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -172,7 +172,7 @@ async def async_modbus_setup(
|
||||
|
||||
async def async_write_register(service: ServiceCall) -> None:
|
||||
"""Write Modbus registers."""
|
||||
slave = 0
|
||||
slave = 1
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
|
||||
@@ -195,7 +195,7 @@ async def async_modbus_setup(
|
||||
|
||||
async def async_write_coil(service: ServiceCall) -> None:
|
||||
"""Write Modbus coil."""
|
||||
slave = 0
|
||||
slave = 1
|
||||
if ATTR_UNIT in service.data:
|
||||
slave = int(float(service.data[ATTR_UNIT]))
|
||||
if ATTR_SLAVE in service.data:
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/motion_blinds",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["motionblinds"],
|
||||
"requirements": ["motionblinds==0.6.29"]
|
||||
"requirements": ["motionblinds==0.6.28"]
|
||||
}
|
||||
|
||||
@@ -2114,9 +2114,6 @@ def data_schema_from_fields(
|
||||
if schema_section is None:
|
||||
data_schema.update(data_schema_element)
|
||||
continue
|
||||
if not data_schema_element:
|
||||
# Do not show empty sections
|
||||
continue
|
||||
collapsed = (
|
||||
not any(
|
||||
(default := data_schema_fields[str(option)].default) is vol.UNDEFINED
|
||||
|
||||
@@ -389,6 +389,16 @@ def async_setup_entity_entry_helper(
|
||||
_async_setup_entities()
|
||||
|
||||
|
||||
def init_entity_id_from_config(
|
||||
hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str
|
||||
) -> None:
|
||||
"""Set entity_id from object_id if defined in config."""
|
||||
if CONF_OBJECT_ID in config:
|
||||
entity.entity_id = async_generate_entity_id(
|
||||
entity_id_format, config[CONF_OBJECT_ID], None, hass
|
||||
)
|
||||
|
||||
|
||||
class MqttAttributesMixin(Entity):
|
||||
"""Mixin used for platforms that support JSON attributes."""
|
||||
|
||||
@@ -1302,7 +1312,6 @@ class MqttEntity(
|
||||
_attr_should_poll = False
|
||||
_default_name: str | None
|
||||
_entity_id_format: str
|
||||
_update_registry_entity_id: str | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -1337,33 +1346,13 @@ class MqttEntity(
|
||||
|
||||
def _init_entity_id(self) -> None:
|
||||
"""Set entity_id from object_id if defined in config."""
|
||||
if CONF_OBJECT_ID not in self._config:
|
||||
return
|
||||
self.entity_id = async_generate_entity_id(
|
||||
self._entity_id_format, self._config[CONF_OBJECT_ID], None, self.hass
|
||||
init_entity_id_from_config(
|
||||
self.hass, self, self._config, self._entity_id_format
|
||||
)
|
||||
if self.unique_id is None:
|
||||
return
|
||||
# Check for previous deleted entities
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entity_platform = self._entity_id_format.split(".")[0]
|
||||
if (
|
||||
deleted_entry := entity_registry.deleted_entities.get(
|
||||
(entity_platform, DOMAIN, self.unique_id)
|
||||
)
|
||||
) and deleted_entry.entity_id != self.entity_id:
|
||||
# Plan to update the entity_id basis on `object_id` if a deleted entity was found
|
||||
self._update_registry_entity_id = self.entity_id
|
||||
|
||||
@final
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to MQTT events."""
|
||||
if self._update_registry_entity_id is not None:
|
||||
entity_registry = er.async_get(self.hass)
|
||||
entity_registry.async_update_entity(
|
||||
self.entity_id, new_entity_id=self._update_registry_entity_id
|
||||
)
|
||||
|
||||
await super().async_added_to_hass()
|
||||
self._subscriptions = {}
|
||||
self._prepare_subscribe_topics()
|
||||
|
||||
@@ -98,12 +98,6 @@ def validate_sensor_state_and_device_class_config(config: ConfigType) -> ConfigT
|
||||
f"together with state class `{state_class}`"
|
||||
)
|
||||
|
||||
unit_of_measurement: str | None
|
||||
if (
|
||||
unit_of_measurement := config.get(CONF_UNIT_OF_MEASUREMENT)
|
||||
) is not None and not unit_of_measurement.strip():
|
||||
config.pop(CONF_UNIT_OF_MEASUREMENT)
|
||||
|
||||
# Only allow `options` to be set for `enum` sensors
|
||||
# to limit the possible sensor values
|
||||
if (options := config.get(CONF_OPTIONS)) is not None:
|
||||
|
||||
@@ -34,7 +34,7 @@ class MusicAssistantEntity(Entity):
|
||||
identifiers={(DOMAIN, player_id)},
|
||||
manufacturer=self.player.device_info.manufacturer or provider.name,
|
||||
model=self.player.device_info.model or self.player.name,
|
||||
name=self.player.name,
|
||||
name=self.player.display_name,
|
||||
configuration_url=f"{mass.server_url}/#/settings/editplayer/{player_id}",
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["music_assistant"],
|
||||
"requirements": ["music-assistant-client==1.2.4"],
|
||||
"requirements": ["music-assistant-client==1.2.0"],
|
||||
"zeroconf": ["_mass._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from music_assistant_models.enums import MediaType as MASSMediaType
|
||||
from music_assistant_models.media_items import MediaItemType, SearchResults
|
||||
from music_assistant_models.media_items import (
|
||||
BrowseFolder,
|
||||
MediaItemType,
|
||||
SearchResults,
|
||||
)
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
@@ -545,6 +549,8 @@ def _process_search_results(
|
||||
|
||||
# Add available items to results
|
||||
for item in items:
|
||||
if TYPE_CHECKING:
|
||||
assert not isinstance(item, BrowseFolder)
|
||||
if not item.available:
|
||||
continue
|
||||
|
||||
|
||||
@@ -248,8 +248,10 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
player = self.player
|
||||
active_queue = self.active_queue
|
||||
# update generic attributes
|
||||
if player.powered and player.playback_state is not None:
|
||||
self._attr_state = MediaPlayerState(player.playback_state.value)
|
||||
if player.powered and active_queue is not None:
|
||||
self._attr_state = MediaPlayerState(active_queue.state.value)
|
||||
if player.powered and player.state is not None:
|
||||
self._attr_state = MediaPlayerState(player.state.value)
|
||||
else:
|
||||
self._attr_state = MediaPlayerState(STATE_OFF)
|
||||
# active source and source list (translate to HA source names)
|
||||
@@ -268,12 +270,12 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
self._attr_source = active_source_name
|
||||
|
||||
group_members: list[str] = []
|
||||
if player.group_members:
|
||||
group_members = player.group_members
|
||||
if player.group_childs:
|
||||
group_members = player.group_childs
|
||||
elif player.synced_to and (parent := self.mass.players.get(player.synced_to)):
|
||||
group_members = parent.group_members
|
||||
group_members = parent.group_childs
|
||||
|
||||
# translate MA group_members to HA group_members as entity id's
|
||||
# translate MA group_childs to HA group_members as entity id's
|
||||
entity_registry = er.async_get(self.hass)
|
||||
group_members_entity_ids: list[str] = [
|
||||
entity_id
|
||||
|
||||
@@ -44,6 +44,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NAMConfigEntry) -> bool:
|
||||
translation_key="device_communication_error",
|
||||
translation_placeholders={"device": entry.title},
|
||||
) from err
|
||||
|
||||
try:
|
||||
await nam.async_check_credentials()
|
||||
except (ApiError, ClientError) as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="device_communication_error",
|
||||
translation_placeholders={"device": entry.title},
|
||||
) from err
|
||||
except AuthFailedError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +26,15 @@ from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@dataclass
|
||||
class NamConfig:
|
||||
"""NAM device configuration class."""
|
||||
|
||||
mac_address: str
|
||||
auth_enabled: bool
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
AUTH_SCHEMA = vol.Schema(
|
||||
@@ -32,14 +42,29 @@ AUTH_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def async_get_nam(
|
||||
hass: HomeAssistant, host: str, data: dict[str, Any]
|
||||
) -> NettigoAirMonitor:
|
||||
"""Get NAM client."""
|
||||
async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig:
|
||||
"""Get device MAC address and auth_enabled property."""
|
||||
websession = async_get_clientsession(hass)
|
||||
|
||||
options = ConnectionOptions(host)
|
||||
nam = await NettigoAirMonitor.create(websession, options)
|
||||
|
||||
mac = await nam.async_get_mac_address()
|
||||
|
||||
return NamConfig(mac, nam.auth_enabled)
|
||||
|
||||
|
||||
async def async_check_credentials(
|
||||
hass: HomeAssistant, host: str, data: dict[str, Any]
|
||||
) -> None:
|
||||
"""Check if credentials are valid."""
|
||||
websession = async_get_clientsession(hass)
|
||||
|
||||
options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD))
|
||||
|
||||
return await NettigoAirMonitor.create(websession, options)
|
||||
nam = await NettigoAirMonitor.create(websession, options)
|
||||
|
||||
await nam.async_check_credentials()
|
||||
|
||||
|
||||
class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -47,8 +72,8 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
_config: NamConfig
|
||||
host: str
|
||||
auth_enabled: bool = False
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -60,20 +85,21 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self.host = user_input[CONF_HOST]
|
||||
|
||||
try:
|
||||
nam = await async_get_nam(self.hass, self.host, {})
|
||||
config = await async_get_config(self.hass, self.host)
|
||||
except (ApiError, ClientConnectorError, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
except CannotGetMacError:
|
||||
return self.async_abort(reason="device_unsupported")
|
||||
except AuthFailedError:
|
||||
return await self.async_step_credentials()
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(format_mac(nam.mac))
|
||||
await self.async_set_unique_id(format_mac(config.mac_address))
|
||||
self._abort_if_unique_id_configured({CONF_HOST: self.host})
|
||||
|
||||
if config.auth_enabled is True:
|
||||
return await self.async_step_credentials()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.host,
|
||||
data=user_input,
|
||||
@@ -93,7 +119,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
nam = await async_get_nam(self.hass, self.host, user_input)
|
||||
await async_check_credentials(self.hass, self.host, user_input)
|
||||
except AuthFailedError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except (ApiError, ClientConnectorError, TimeoutError):
|
||||
@@ -102,9 +128,6 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(format_mac(nam.mac))
|
||||
self._abort_if_unique_id_configured({CONF_HOST: self.host})
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.host,
|
||||
data={**user_input, CONF_HOST: self.host},
|
||||
@@ -125,16 +148,14 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._async_abort_entries_match({CONF_HOST: self.host})
|
||||
|
||||
try:
|
||||
nam = await async_get_nam(self.hass, self.host, {})
|
||||
self._config = await async_get_config(self.hass, self.host)
|
||||
except (ApiError, ClientConnectorError, TimeoutError):
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
except CannotGetMacError:
|
||||
return self.async_abort(reason="device_unsupported")
|
||||
except AuthFailedError:
|
||||
self.auth_enabled = True
|
||||
return await self.async_step_confirm_discovery()
|
||||
|
||||
await self.async_set_unique_id(format_mac(nam.mac))
|
||||
await self.async_set_unique_id(format_mac(self._config.mac_address))
|
||||
self._abort_if_unique_id_configured({CONF_HOST: self.host})
|
||||
|
||||
return await self.async_step_confirm_discovery()
|
||||
|
||||
@@ -150,7 +171,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data={CONF_HOST: self.host},
|
||||
)
|
||||
|
||||
if self.auth_enabled is True:
|
||||
if self._config.auth_enabled is True:
|
||||
return await self.async_step_credentials()
|
||||
|
||||
self._set_confirm_only()
|
||||
@@ -177,7 +198,7 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
await async_get_nam(self.hass, self.host, user_input)
|
||||
await async_check_credentials(self.hass, self.host, user_input)
|
||||
except (
|
||||
ApiError,
|
||||
AuthFailedError,
|
||||
@@ -207,11 +228,11 @@ class NAMFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
nam = await async_get_nam(self.hass, user_input[CONF_HOST], {})
|
||||
config = await async_get_config(self.hass, user_input[CONF_HOST])
|
||||
except (ApiError, ClientConnectorError, TimeoutError):
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
await self.async_set_unique_id(format_mac(nam.mac))
|
||||
await self.async_set_unique_id(format_mac(config.mac_address))
|
||||
self._abort_if_unique_id_mismatch(reason="another_device")
|
||||
|
||||
return self.async_update_reload_and_abort(
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["nettigo_air_monitor"],
|
||||
"requirements": ["nettigo-air-monitor==5.0.0"],
|
||||
"requirements": ["nettigo-air-monitor==4.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_http._tcp.local.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user