mirror of
https://github.com/home-assistant/core.git
synced 2026-04-28 18:12:37 +02:00
Compare commits
253 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c029fafdb | |||
| 94f6daa09c | |||
| 79a9f3f2c6 | |||
| 54751ef0c7 | |||
| c203307b0d | |||
| eb5036854f | |||
| 6ff9b0541e | |||
| fed36d5756 | |||
| 24ca7d95ac | |||
| 0895ac6a82 | |||
| f19404991c | |||
| 0c56791d94 | |||
| 5dd03c037e | |||
| 1f7d620d6b | |||
| 9a9374bf45 | |||
| 2f5816c5b6 | |||
| 5629b995ce | |||
| 345cbc62a7 | |||
| a4f0194786 | |||
| ffc6aa0035 | |||
| 3e45af9995 | |||
| cd028f8d21 | |||
| dd1def3c5d | |||
| 0a32a9d6db | |||
| d1d498e27d | |||
| 9a565885cb | |||
| 7f69c689bf | |||
| efc515ff4e | |||
| 64a40a3396 | |||
| ca53d97a6d | |||
| e18062bce4 | |||
| 30c0a1492c | |||
| 43b034b8bb | |||
| b98b38b3f0 | |||
| 09cea6ce96 | |||
| 650351a7f3 | |||
| c3b40e681d | |||
| 4ce3fa8813 | |||
| ea3ccc02d7 | |||
| 0c55538370 | |||
| 6bd3792e9f | |||
| 5e0312ca60 | |||
| 0f57347797 | |||
| 82369535c4 | |||
| f9cc3361e3 | |||
| 42cab208d0 | |||
| 7fe89ea329 | |||
| 1654c28d74 | |||
| 6fa87da5bd | |||
| 649319f4ee | |||
| 282560acf8 | |||
| 1680adf158 | |||
| 5a14409dda | |||
| 3bfc1a87c8 | |||
| 28edbdc107 | |||
| 58b7be7c2f | |||
| a41566611e | |||
| b660703117 | |||
| c5e60045b4 | |||
| ce5be8686a | |||
| 94daeffe44 | |||
| 9856340a33 | |||
| 30af9057d1 | |||
| a5eda3faf1 | |||
| 2682f4a323 | |||
| 628e1ffb84 | |||
| 05ca80f4ba | |||
| 8acab6c646 | |||
| 4531a46557 | |||
| 37461d727a | |||
| b5662ded2c | |||
| 71e28a4af3 | |||
| dba4637aa9 | |||
| e24564147d | |||
| 9bc110104d | |||
| c903658aa8 | |||
| 34a229af52 | |||
| 8579456895 | |||
| 1d7e485aa3 | |||
| 0e73363d04 | |||
| 48184e742a | |||
| 0034055ac8 | |||
| 52d7cfbe32 | |||
| a9e73d9253 | |||
| a5c01a4d4f | |||
| 6d31530811 | |||
| c950c69cb3 | |||
| c2f94542aa | |||
| cce6c735ad | |||
| 9cfe109210 | |||
| 0b2b222fca | |||
| d2092315f5 | |||
| d18fb4e6f9 | |||
| 00e0a5bc10 | |||
| 9679fc7878 | |||
| ce93cb9467 | |||
| 1860794cac | |||
| f846aa4705 | |||
| 0f641fcb74 | |||
| 0f36759a38 | |||
| a6781107df | |||
| 6afaeee0fd | |||
| 1a394876b1 | |||
| a98109614e | |||
| a3d0ec4e6e | |||
| 839e2881e0 | |||
| cb3ed506ad | |||
| 9d808a7b5a | |||
| b8237eaa55 | |||
| 9c747113a2 | |||
| 634e1dd9eb | |||
| 9fcaf32c9c | |||
| d55a6de01b | |||
| dd9bd8ef73 | |||
| 2ce585463c | |||
| f9df5b413b | |||
| 39a575dd29 | |||
| 27f89f7710 | |||
| 2f6640707b | |||
| 30314ca32b | |||
| 147b5f549f | |||
| bf6f790d09 | |||
| 2c99e3778e | |||
| 51c16cc808 | |||
| f5fd49d8cb | |||
| ba427a1054 | |||
| 95bcbd2c4f | |||
| c35cd6fb76 | |||
| 3b69a2bbd1 | |||
| d402166d1d | |||
| 9f85756785 | |||
| d28a4258a3 | |||
| caaa7def2f | |||
| bfb9de46fe | |||
| ced52f64b4 | |||
| 5967957e0b | |||
| 2888c64da9 | |||
| 4cab773bab | |||
| d3da3b3470 | |||
| 9c4940e915 | |||
| d43083e2f9 | |||
| 1157a08f72 | |||
| 278c35f830 | |||
| f29b4134d2 | |||
| da7ba85ee6 | |||
| 37daa57818 | |||
| ee37bc476f | |||
| d4586fb2e4 | |||
| 63ab13681a | |||
| efcfd97d1b | |||
| 889fe05a48 | |||
| 123cd92986 | |||
| 285a0a6c81 | |||
| 012f7112d7 | |||
| bb61e31298 | |||
| 9453b925cd | |||
| 64d2f84c0d | |||
| 84e15e10ef | |||
| 5da9bfe0e3 | |||
| e56772d37b | |||
| c35e7715b7 | |||
| 7040614433 | |||
| 5fa5bd1302 | |||
| dc7f445356 | |||
| 7a0400154e | |||
| d51e72cd95 | |||
| 7103ea7e8f | |||
| 164d38ac0d | |||
| 4a2e9db9fe | |||
| df166d178c | |||
| f75a61ac90 | |||
| 92dd18a9be | |||
| df59b1d4fa | |||
| 9bc3c417ae | |||
| 065cdf421f | |||
| 256157d413 | |||
| f8f12957b5 | |||
| c4cb94bddd | |||
| 64f679ba8f | |||
| e0bf248867 | |||
| b1c3d0857a | |||
| e18dc063ba | |||
| b85b834bdc | |||
| f5924146c1 | |||
| fafeedd01b | |||
| 64814e086f | |||
| 6f1539f60d | |||
| 84ae476b67 | |||
| 21ffcf853b | |||
| d4a355e684 | |||
| 0773e37dab | |||
| 8eb9cc0e8e | |||
| b702d88ab7 | |||
| 66f048f49f | |||
| c7041a97be | |||
| f21ab24b8b | |||
| cde59613a5 | |||
| d83c335ed6 | |||
| 50f3d79fb2 | |||
| a7903d344f | |||
| 010cad08c0 | |||
| e512ad7a81 | |||
| e578327054 | |||
| 230e101ee4 | |||
| 3fb70316da | |||
| ab5583ed40 | |||
| f1c720606f | |||
| 270108e8e4 | |||
| fc979cd564 | |||
| 99e307fe5a | |||
| 4d4e11a0eb | |||
| 4613087e86 | |||
| 6c93d6a2d0 | |||
| f93b1cc950 | |||
| 00f8afe332 | |||
| ea496290c2 | |||
| acb3f4ed78 | |||
| b12598d963 | |||
| cf737356fd | |||
| 6858f2a3d2 | |||
| c3b0bc3e0d | |||
| 3dc52774fc | |||
| f501b55aed | |||
| eca93f1f4e | |||
| ec53b08e09 | |||
| 63af407f8f | |||
| 6dd2d46328 | |||
| 8db6a6cf17 | |||
| d148bd9b0c | |||
| 773375e7b0 | |||
| 232e99b62e | |||
| bab616fa61 | |||
| 1c4ddb36d5 | |||
| 76570b5144 | |||
| 5dd147e83b | |||
| 9eb383f314 | |||
| 52feeedd2b | |||
| 1b5316b269 | |||
| 708ae09c7a | |||
| 97fcbed6e0 | |||
| a8175b785f | |||
| 64b056fbe9 | |||
| 427c437a68 | |||
| b637129208 | |||
| 4e3e1e91b7 | |||
| 4066289662 | |||
| aca9607e2f | |||
| edabf0f8dd | |||
| 5286bd8f0c | |||
| d206553a0d | |||
| b500fde468 | |||
| 46cef2986c | |||
| 823df4242d |
@@ -46,6 +46,8 @@
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue:
|
||||
- Link to documentation pull request:
|
||||
- Link to developer documentation pull request:
|
||||
- Link to frontend pull request:
|
||||
|
||||
## Checklist
|
||||
<!--
|
||||
|
||||
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 11
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.2"
|
||||
HA_SHORT_VERSION: "2025.3"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.6
|
||||
uses: github/codeql-action/init@v3.28.8
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.6
|
||||
uses: github/codeql-action/analyze@v3.28.8
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -8,7 +8,7 @@ repos:
|
||||
- id: ruff-format
|
||||
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
|
||||
- repo: https://github.com/codespell-project/codespell
|
||||
rev: v2.3.0
|
||||
rev: v2.4.1
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
|
||||
Generated
+4
-4
@@ -625,8 +625,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/hlk_sw16/ @jameshilliard
|
||||
/homeassistant/components/holiday/ @jrieger @gjohansson-ST
|
||||
/tests/components/holiday/ @jrieger @gjohansson-ST
|
||||
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98
|
||||
/tests/components/home_connect/ @DavidMStraub @Diegorro98
|
||||
/homeassistant/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
|
||||
/tests/components/home_connect/ @DavidMStraub @Diegorro98 @MartinHjelmare
|
||||
/homeassistant/components/homeassistant/ @home-assistant/core
|
||||
/tests/components/homeassistant/ @home-assistant/core
|
||||
/homeassistant/components/homeassistant_alerts/ @home-assistant/core
|
||||
@@ -765,8 +765,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/ituran/ @shmuelzon
|
||||
/homeassistant/components/izone/ @Swamp-Ig
|
||||
/tests/components/izone/ @Swamp-Ig
|
||||
/homeassistant/components/jellyfin/ @j-stienstra @ctalkington
|
||||
/tests/components/jellyfin/ @j-stienstra @ctalkington
|
||||
/homeassistant/components/jellyfin/ @RunC0deRun @ctalkington
|
||||
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||
/homeassistant/components/jewish_calendar/ @tsvi
|
||||
/tests/components/jewish_calendar/ @tsvi
|
||||
/homeassistant/components/juicenet/ @jesserockz
|
||||
|
||||
Generated
+1
-1
@@ -13,7 +13,7 @@ ENV \
|
||||
ARG QEMU_CPU
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.5.21
|
||||
RUN pip3 install uv==0.5.27
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ def _extract_backup(
|
||||
config_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns(*(keep)),
|
||||
ignore_dangling_symlinks=True,
|
||||
)
|
||||
elif restore_content.restore_database:
|
||||
for entry in KEEP_DATABASE:
|
||||
|
||||
@@ -161,6 +161,16 @@ FRONTEND_INTEGRATIONS = {
|
||||
# integrations can be removed and database migration status is
|
||||
# visible in frontend
|
||||
"frontend",
|
||||
# Hassio is an after dependency of backup, after dependencies
|
||||
# are not promoted from stage 2 to earlier stages, so we need to
|
||||
# add it here. Hassio needs to be setup before backup, otherwise
|
||||
# the backup integration will think we are a container/core install
|
||||
# when using HAOS or Supervised install.
|
||||
"hassio",
|
||||
# Backup is an after dependency of frontend, after dependencies
|
||||
# are not promoted from stage 2 to earlier stages, so we need to
|
||||
# add it here.
|
||||
"backup",
|
||||
}
|
||||
RECORDER_INTEGRATIONS = {
|
||||
# Setup after frontend
|
||||
|
||||
@@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_create_entry(title=discovery.name, data={})
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery_info in async_discovered_service_info(self.hass):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["python_homeassistant_analytics"],
|
||||
"requirements": ["python-homeassistant-analytics==0.8.1"],
|
||||
"requirements": ["python-homeassistant-analytics==0.9.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -272,6 +272,7 @@ class AnthropicConversationEntity(
|
||||
continue
|
||||
|
||||
tool_input = llm.ToolInput(
|
||||
id=tool_call.id,
|
||||
tool_name=tool_call.name,
|
||||
tool_args=cast(dict[str, Any], tool_call.input),
|
||||
)
|
||||
|
||||
@@ -134,7 +134,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
unique_id for said entry. When a new (zeroconf) service or device is
|
||||
discovered, the identifier is first used to look up if it belongs to an
|
||||
existing config entry. If that's the case, the unique_id from that entry is
|
||||
re-used, otherwise the newly discovered identifier is used instead.
|
||||
reused, otherwise the newly discovered identifier is used instead.
|
||||
"""
|
||||
assert self.atv
|
||||
all_identifiers = set(self.atv.all_identifiers)
|
||||
|
||||
@@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=self._discovered_devices[address][0], data={}
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
|
||||
@@ -9,6 +9,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import stt
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.helpers import chat_session
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
@@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream(
|
||||
|
||||
Raises PipelineNotFound if no pipeline is found.
|
||||
"""
|
||||
pipeline_input = PipelineInput(
|
||||
conversation_id=conversation_id,
|
||||
device_id=device_id,
|
||||
stt_metadata=stt_metadata,
|
||||
stt_stream=stt_stream,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
conversation_extra_system_prompt=conversation_extra_system_prompt,
|
||||
run=PipelineRun(
|
||||
hass,
|
||||
context=context,
|
||||
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
event_callback=event_callback,
|
||||
tts_audio_output=tts_audio_output,
|
||||
wake_word_settings=wake_word_settings,
|
||||
audio_settings=audio_settings or AudioSettings(),
|
||||
),
|
||||
)
|
||||
await pipeline_input.validate()
|
||||
await pipeline_input.execute()
|
||||
with chat_session.async_get_chat_session(hass, conversation_id) as session:
|
||||
pipeline_input = PipelineInput(
|
||||
conversation_id=session.conversation_id,
|
||||
device_id=device_id,
|
||||
stt_metadata=stt_metadata,
|
||||
stt_stream=stt_stream,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
conversation_extra_system_prompt=conversation_extra_system_prompt,
|
||||
run=PipelineRun(
|
||||
hass,
|
||||
context=context,
|
||||
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
event_callback=event_callback,
|
||||
tts_audio_output=tts_audio_output,
|
||||
wake_word_settings=wake_word_settings,
|
||||
audio_settings=audio_settings or AudioSettings(),
|
||||
),
|
||||
)
|
||||
await pipeline_input.validate()
|
||||
await pipeline_input.execute()
|
||||
|
||||
@@ -33,7 +33,7 @@ from homeassistant.components.tts import (
|
||||
from homeassistant.const import MATCH_ALL
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.helpers import chat_session, intent
|
||||
from homeassistant.helpers.collection import (
|
||||
CHANGE_UPDATED,
|
||||
CollectionError,
|
||||
@@ -624,7 +624,7 @@ class PipelineRun:
|
||||
return
|
||||
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event)
|
||||
|
||||
def start(self, device_id: str | None) -> None:
|
||||
def start(self, conversation_id: str, device_id: str | None) -> None:
|
||||
"""Emit run start event."""
|
||||
self._device_id = device_id
|
||||
self._start_debug_recording_thread()
|
||||
@@ -632,6 +632,7 @@ class PipelineRun:
|
||||
data = {
|
||||
"pipeline": self.pipeline.id,
|
||||
"language": self.language,
|
||||
"conversation_id": conversation_id,
|
||||
}
|
||||
if self.runner_data is not None:
|
||||
data["runner_data"] = self.runner_data
|
||||
@@ -1015,7 +1016,7 @@ class PipelineRun:
|
||||
async def recognize_intent(
|
||||
self,
|
||||
intent_input: str,
|
||||
conversation_id: str | None,
|
||||
conversation_id: str,
|
||||
device_id: str | None,
|
||||
conversation_extra_system_prompt: str | None,
|
||||
) -> str:
|
||||
@@ -1063,11 +1064,11 @@ class PipelineRun:
|
||||
agent_id=self.intent_agent,
|
||||
extra_system_prompt=conversation_extra_system_prompt,
|
||||
)
|
||||
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
|
||||
|
||||
agent_id = user_input.agent_id
|
||||
agent_id = self.intent_agent
|
||||
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
|
||||
intent_response: intent.IntentResponse | None = None
|
||||
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
|
||||
if not processed_locally:
|
||||
# Sentence triggers override conversation agent
|
||||
if (
|
||||
trigger_response_text
|
||||
@@ -1094,22 +1095,26 @@ class PipelineRun:
|
||||
|
||||
# It was already handled, create response and add to chat history
|
||||
if intent_response is not None:
|
||||
async with conversation.async_get_chat_session(
|
||||
self.hass, user_input
|
||||
) as chat_session:
|
||||
with (
|
||||
chat_session.async_get_chat_session(
|
||||
self.hass, user_input.conversation_id
|
||||
) as session,
|
||||
conversation.async_get_chat_log(
|
||||
self.hass, session, user_input
|
||||
) as chat_log,
|
||||
):
|
||||
speech: str = intent_response.speech.get("plain", {}).get(
|
||||
"speech", ""
|
||||
)
|
||||
chat_session.async_add_message(
|
||||
conversation.Content(
|
||||
role="assistant",
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id=agent_id,
|
||||
content=speech,
|
||||
)
|
||||
)
|
||||
conversation_result = conversation.ConversationResult(
|
||||
response=intent_response,
|
||||
conversation_id=chat_session.conversation_id,
|
||||
conversation_id=session.conversation_id,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -1122,6 +1127,7 @@ class PipelineRun:
|
||||
context=user_input.context,
|
||||
language=user_input.language,
|
||||
agent_id=user_input.agent_id,
|
||||
extra_system_prompt=user_input.extra_system_prompt,
|
||||
)
|
||||
speech = conversation_result.response.speech.get("plain", {}).get(
|
||||
"speech", ""
|
||||
@@ -1403,12 +1409,15 @@ def _pipeline_debug_recording_thread_proc(
|
||||
wav_writer.close()
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(kw_only=True)
|
||||
class PipelineInput:
|
||||
"""Input to a pipeline run."""
|
||||
|
||||
run: PipelineRun
|
||||
|
||||
conversation_id: str
|
||||
"""Identifier for the conversation."""
|
||||
|
||||
stt_metadata: stt.SpeechMetadata | None = None
|
||||
"""Metadata of stt input audio. Required when start_stage = stt."""
|
||||
|
||||
@@ -1424,9 +1433,6 @@ class PipelineInput:
|
||||
tts_input: str | None = None
|
||||
"""Input for text-to-speech. Required when start_stage = tts."""
|
||||
|
||||
conversation_id: str | None = None
|
||||
"""Identifier for the conversation."""
|
||||
|
||||
conversation_extra_system_prompt: str | None = None
|
||||
"""Extra prompt information for the conversation agent."""
|
||||
|
||||
@@ -1435,7 +1441,7 @@ class PipelineInput:
|
||||
|
||||
async def execute(self) -> None:
|
||||
"""Run pipeline."""
|
||||
self.run.start(device_id=self.device_id)
|
||||
self.run.start(conversation_id=self.conversation_id, device_id=self.device_id)
|
||||
current_stage: PipelineStage | None = self.run.start_stage
|
||||
stt_audio_buffer: list[EnhancedAudioChunk] = []
|
||||
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
|
||||
|
||||
@@ -14,7 +14,11 @@ import voluptuous as vol
|
||||
from homeassistant.components import conversation, stt, tts, websocket_api
|
||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers import (
|
||||
chat_session,
|
||||
config_validation as cv,
|
||||
entity_registry as er,
|
||||
)
|
||||
from homeassistant.util import language as language_util
|
||||
|
||||
from .const import (
|
||||
@@ -145,7 +149,6 @@ async def websocket_run(
|
||||
|
||||
# Arguments to PipelineInput
|
||||
input_args: dict[str, Any] = {
|
||||
"conversation_id": msg.get("conversation_id"),
|
||||
"device_id": msg.get("device_id"),
|
||||
}
|
||||
|
||||
@@ -233,38 +236,42 @@ async def websocket_run(
|
||||
audio_settings=audio_settings or AudioSettings(),
|
||||
)
|
||||
|
||||
pipeline_input = PipelineInput(**input_args)
|
||||
with chat_session.async_get_chat_session(
|
||||
hass, msg.get("conversation_id")
|
||||
) as session:
|
||||
input_args["conversation_id"] = session.conversation_id
|
||||
pipeline_input = PipelineInput(**input_args)
|
||||
|
||||
try:
|
||||
await pipeline_input.validate()
|
||||
except PipelineError as error:
|
||||
# Report more specific error when possible
|
||||
connection.send_error(msg["id"], error.code, error.message)
|
||||
return
|
||||
try:
|
||||
await pipeline_input.validate()
|
||||
except PipelineError as error:
|
||||
# Report more specific error when possible
|
||||
connection.send_error(msg["id"], error.code, error.message)
|
||||
return
|
||||
|
||||
# Confirm subscription
|
||||
connection.send_result(msg["id"])
|
||||
# Confirm subscription
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
run_task = hass.async_create_task(pipeline_input.execute())
|
||||
run_task = hass.async_create_task(pipeline_input.execute())
|
||||
|
||||
# Cancel pipeline if user unsubscribes
|
||||
connection.subscriptions[msg["id"]] = run_task.cancel
|
||||
# Cancel pipeline if user unsubscribes
|
||||
connection.subscriptions[msg["id"]] = run_task.cancel
|
||||
|
||||
try:
|
||||
# Task contains a timeout
|
||||
async with asyncio.timeout(timeout):
|
||||
await run_task
|
||||
except TimeoutError:
|
||||
pipeline_input.run.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.ERROR,
|
||||
{"code": "timeout", "message": "Timeout running pipeline"},
|
||||
try:
|
||||
# Task contains a timeout
|
||||
async with asyncio.timeout(timeout):
|
||||
await run_task
|
||||
except TimeoutError:
|
||||
pipeline_input.run.process_event(
|
||||
PipelineEvent(
|
||||
PipelineEventType.ERROR,
|
||||
{"code": "timeout", "message": "Timeout running pipeline"},
|
||||
)
|
||||
)
|
||||
)
|
||||
finally:
|
||||
if unregister_handler is not None:
|
||||
# Unregister binary handler
|
||||
unregister_handler()
|
||||
finally:
|
||||
if unregister_handler is not None:
|
||||
# Unregister binary handler
|
||||
unregister_handler()
|
||||
|
||||
|
||||
@callback
|
||||
|
||||
@@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
"start_conversation",
|
||||
vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("start_message", "start_media_id"),
|
||||
),
|
||||
"async_internal_start_conversation",
|
||||
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||
)
|
||||
hass.data[CONNECTION_TEST_DATA] = {}
|
||||
async_register_websocket_api(hass)
|
||||
hass.http.register_view(ConnectionTestView())
|
||||
|
||||
@@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag):
|
||||
|
||||
ANNOUNCE = 1
|
||||
"""Device supports remotely triggered announcements."""
|
||||
|
||||
START_CONVERSATION = 2
|
||||
"""Device supports starting conversations."""
|
||||
|
||||
@@ -8,9 +8,9 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Final, Literal, final
|
||||
from typing import Any, Literal, final
|
||||
|
||||
from homeassistant.components import media_source, stt, tts
|
||||
from homeassistant.components import conversation, media_source, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
OPTION_PREFERRED,
|
||||
AudioSettings,
|
||||
@@ -27,14 +27,13 @@ from homeassistant.components.tts import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.core import Context, callback
|
||||
from homeassistant.helpers import entity
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import chat_session, entity
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
|
||||
from .const import AssistSatelliteEntityFeature
|
||||
from .errors import AssistSatelliteError, SatelliteBusyError
|
||||
|
||||
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -113,10 +112,10 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
_attr_vad_sensitivity_entity_id: str | None = None
|
||||
|
||||
_conversation_id: str | None = None
|
||||
_conversation_id_time: float | None = None
|
||||
|
||||
_run_has_tts: bool = False
|
||||
_is_announcing = False
|
||||
_extra_system_prompt: str | None = None
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_attr_tts_options: dict[str, Any] | None = None
|
||||
_pipeline_task: asyncio.Task | None = None
|
||||
@@ -216,6 +215,78 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_internal_start_conversation(
|
||||
self,
|
||||
start_message: str | None = None,
|
||||
start_media_id: str | None = None,
|
||||
extra_system_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite.
|
||||
|
||||
If start_media_id is not provided, message is synthesized to
|
||||
audio with the selected pipeline.
|
||||
|
||||
If start_media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
# The Home Assistant built-in agent doesn't support conversations.
|
||||
pipeline = async_get_pipeline(self.hass, self._resolve_pipeline())
|
||||
if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT:
|
||||
raise HomeAssistantError(
|
||||
"Built-in conversation agent does not support starting conversations"
|
||||
)
|
||||
|
||||
if start_message is None:
|
||||
start_message = ""
|
||||
|
||||
announcement = await self._resolve_announcement_media_id(
|
||||
start_message, start_media_id
|
||||
)
|
||||
|
||||
if self._is_announcing:
|
||||
raise SatelliteBusyError
|
||||
|
||||
self._is_announcing = True
|
||||
# Provide our start info to the LLM so it understands context of incoming message
|
||||
if extra_system_prompt is not None:
|
||||
self._extra_system_prompt = extra_system_prompt
|
||||
else:
|
||||
self._extra_system_prompt = start_message or None
|
||||
|
||||
with (
|
||||
# Not passing in a conversation ID will force a new one to be created
|
||||
chat_session.async_get_chat_session(self.hass) as session,
|
||||
conversation.async_get_chat_log(self.hass, session) as chat_log,
|
||||
):
|
||||
self._conversation_id = session.conversation_id
|
||||
|
||||
if start_message:
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
conversation.AssistantContent(
|
||||
agent_id=self.entity_id, content=start_message
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await self.async_start_conversation(announcement)
|
||||
except Exception:
|
||||
# Clear prompt on error
|
||||
self._conversation_id = None
|
||||
self._extra_system_prompt = None
|
||||
raise
|
||||
finally:
|
||||
self._is_announcing = False
|
||||
|
||||
async def async_start_conversation(
|
||||
self, start_announcement: AssistSatelliteAnnouncement
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_accept_pipeline_from_satellite(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
@@ -226,6 +297,10 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
"""Triggers an Assist pipeline in Home Assistant from a satellite."""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
# Consume system prompt in first pipeline
|
||||
extra_system_prompt = self._extra_system_prompt
|
||||
self._extra_system_prompt = None
|
||||
|
||||
if self._wake_word_intercept_future and start_stage in (
|
||||
PipelineStage.WAKE_WORD,
|
||||
PipelineStage.STT,
|
||||
@@ -266,50 +341,52 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
|
||||
assert self._context is not None
|
||||
|
||||
# Reset conversation id if necessary
|
||||
if self._conversation_id_time and (
|
||||
(time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC
|
||||
):
|
||||
self._conversation_id = None
|
||||
self._conversation_id_time = None
|
||||
|
||||
# Set entity state based on pipeline events
|
||||
self._run_has_tts = False
|
||||
|
||||
assert self.platform.config_entry is not None
|
||||
self._pipeline_task = self.platform.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self._context,
|
||||
event_callback=self._internal_on_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=audio_stream,
|
||||
pipeline_id=self._resolve_pipeline(),
|
||||
conversation_id=self._conversation_id,
|
||||
device_id=device_id,
|
||||
tts_audio_output=self.tts_options,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
audio_settings=AudioSettings(
|
||||
silence_seconds=self._resolve_vad_sensitivity()
|
||||
),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
),
|
||||
f"{self.entity_id}_pipeline",
|
||||
)
|
||||
|
||||
try:
|
||||
await self._pipeline_task
|
||||
finally:
|
||||
self._pipeline_task = None
|
||||
with chat_session.async_get_chat_session(
|
||||
self.hass, self._conversation_id
|
||||
) as session:
|
||||
# Store the conversation ID. If it is no longer valid, get_chat_session will reset it
|
||||
self._conversation_id = session.conversation_id
|
||||
self._pipeline_task = (
|
||||
self.platform.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
async_pipeline_from_audio_stream(
|
||||
self.hass,
|
||||
context=self._context,
|
||||
event_callback=self._internal_on_pipeline_event,
|
||||
stt_metadata=stt.SpeechMetadata(
|
||||
language="", # set in async_pipeline_from_audio_stream
|
||||
format=stt.AudioFormats.WAV,
|
||||
codec=stt.AudioCodecs.PCM,
|
||||
bit_rate=stt.AudioBitRates.BITRATE_16,
|
||||
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
|
||||
channel=stt.AudioChannels.CHANNEL_MONO,
|
||||
),
|
||||
stt_stream=audio_stream,
|
||||
pipeline_id=self._resolve_pipeline(),
|
||||
conversation_id=session.conversation_id,
|
||||
device_id=device_id,
|
||||
tts_audio_output=self.tts_options,
|
||||
wake_word_phrase=wake_word_phrase,
|
||||
audio_settings=AudioSettings(
|
||||
silence_seconds=self._resolve_vad_sensitivity()
|
||||
),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
conversation_extra_system_prompt=extra_system_prompt,
|
||||
),
|
||||
f"{self.entity_id}_pipeline",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await self._pipeline_task
|
||||
finally:
|
||||
self._pipeline_task = None
|
||||
|
||||
async def _cancel_running_pipeline(self) -> None:
|
||||
"""Cancel the current pipeline if it's running."""
|
||||
@@ -333,11 +410,6 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
self._set_state(AssistSatelliteState.LISTENING)
|
||||
elif event.type is PipelineEventType.INTENT_START:
|
||||
self._set_state(AssistSatelliteState.PROCESSING)
|
||||
elif event.type is PipelineEventType.INTENT_END:
|
||||
assert event.data is not None
|
||||
# Update timeout
|
||||
self._conversation_id_time = time.monotonic()
|
||||
self._conversation_id = event.data["intent_output"]["conversation_id"]
|
||||
elif event.type is PipelineEventType.TTS_START:
|
||||
# Wait until tts_response_finished is called to return to waiting state
|
||||
self._run_has_tts = True
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"services": {
|
||||
"announce": {
|
||||
"service": "mdi:bullhorn"
|
||||
},
|
||||
"start_conversation": {
|
||||
"service": "mdi:forum"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,23 @@ announce:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
start_conversation:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
fields:
|
||||
start_message:
|
||||
required: false
|
||||
example: "You left the lights on in the living room. Turn them off?"
|
||||
selector:
|
||||
text:
|
||||
start_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
extra_system_prompt:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"services": {
|
||||
"announce": {
|
||||
"name": "Announce",
|
||||
"description": "Let the satellite announce a message.",
|
||||
"description": "Lets a satellite announce a message.",
|
||||
"fields": {
|
||||
"message": {
|
||||
"name": "Message",
|
||||
@@ -25,6 +25,24 @@
|
||||
"description": "The media ID to announce instead of using text-to-speech."
|
||||
}
|
||||
}
|
||||
},
|
||||
"start_conversation": {
|
||||
"name": "Start conversation",
|
||||
"description": "Starts a conversation from a satellite.",
|
||||
"fields": {
|
||||
"start_message": {
|
||||
"name": "Message",
|
||||
"description": "The message to start with."
|
||||
},
|
||||
"start_media_id": {
|
||||
"name": "Media ID",
|
||||
"description": "The media ID to start with instead of using text-to-speech."
|
||||
},
|
||||
"extra_system_prompt": {
|
||||
"name": "Extra system prompt",
|
||||
"description": "Provide background information to the AI about the request."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"]
|
||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"]
|
||||
}
|
||||
|
||||
@@ -26,14 +26,19 @@ from .manager import (
|
||||
BackupReaderWriterError,
|
||||
CoreBackupReaderWriter,
|
||||
CreateBackupEvent,
|
||||
CreateBackupStage,
|
||||
CreateBackupState,
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupStage,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
)
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
|
||||
from .util import suggested_filename, suggested_filename_from_name_date
|
||||
from .websocket import async_register_websocket_handlers
|
||||
|
||||
__all__ = [
|
||||
@@ -43,10 +48,13 @@ __all__ = [
|
||||
"BackupAgentError",
|
||||
"BackupAgentPlatformProtocol",
|
||||
"BackupManagerError",
|
||||
"BackupNotFound",
|
||||
"BackupPlatformProtocol",
|
||||
"BackupReaderWriter",
|
||||
"BackupReaderWriterError",
|
||||
"CreateBackupEvent",
|
||||
"CreateBackupStage",
|
||||
"CreateBackupState",
|
||||
"Folder",
|
||||
"IdleEvent",
|
||||
"IncorrectPasswordError",
|
||||
@@ -54,8 +62,12 @@ __all__ = [
|
||||
"ManagerBackup",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupStage",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
"suggested_filename",
|
||||
"suggested_filename_from_name_date",
|
||||
]
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
@@ -11,13 +11,7 @@ from propcache.api import cached_property
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from .models import AgentBackup, BackupError
|
||||
|
||||
|
||||
class BackupAgentError(BackupError):
|
||||
"""Base class for backup agent errors."""
|
||||
|
||||
error_code = "backup_agent_error"
|
||||
from .models import AgentBackup, BackupAgentError
|
||||
|
||||
|
||||
class BackupAgentUnreachableError(BackupAgentError):
|
||||
@@ -94,11 +88,16 @@ class LocalBackupAgent(BackupAgent):
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_backup_path(self, backup_id: str) -> Path:
|
||||
"""Return the local path to a backup.
|
||||
"""Return the local path to an existing backup.
|
||||
|
||||
The method should return the path to the backup file with the specified id.
|
||||
Raises BackupAgentError if the backup does not exist.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
|
||||
|
||||
class BackupAgentPlatformProtocol(Protocol):
|
||||
"""Define the format of backup platforms which implement backup agents."""
|
||||
|
||||
@@ -13,8 +13,8 @@ from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup
|
||||
from .util import read_backup
|
||||
from .models import AgentBackup, BackupNotFound
|
||||
from .util import read_backup, suggested_filename
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
@@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._backup_dir = Path(hass.config.path("backups"))
|
||||
self._backups: dict[str, AgentBackup] = {}
|
||||
self._backups: dict[str, tuple[AgentBackup, Path]] = {}
|
||||
self._loaded_backups = False
|
||||
|
||||
async def _load_backups(self) -> None:
|
||||
@@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
self._backups = backups
|
||||
self._loaded_backups = True
|
||||
|
||||
def _read_backups(self) -> dict[str, AgentBackup]:
|
||||
def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
|
||||
"""Read backups from disk."""
|
||||
backups: dict[str, AgentBackup] = {}
|
||||
backups: dict[str, tuple[AgentBackup, Path]] = {}
|
||||
for backup_path in self._backup_dir.glob("*.tar"):
|
||||
try:
|
||||
backup = read_backup(backup_path)
|
||||
backups[backup.backup_id] = backup
|
||||
backups[backup.backup_id] = (backup, backup_path)
|
||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||
return backups
|
||||
@@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
self._backups[backup.backup_id] = backup
|
||||
self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
if not self._loaded_backups:
|
||||
await self._load_backups()
|
||||
return list(self._backups.values())
|
||||
return [backup for backup, _ in self._backups.values()]
|
||||
|
||||
async def async_get_backup(
|
||||
self,
|
||||
@@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
if not self._loaded_backups:
|
||||
await self._load_backups()
|
||||
|
||||
if not (backup := self._backups.get(backup_id)):
|
||||
if backup_id not in self._backups:
|
||||
return None
|
||||
|
||||
backup_path = self.get_backup_path(backup_id)
|
||||
backup, backup_path = self._backups[backup_id]
|
||||
if not await self._hass.async_add_executor_job(backup_path.exists):
|
||||
LOGGER.debug(
|
||||
(
|
||||
@@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
return backup
|
||||
|
||||
def get_backup_path(self, backup_id: str) -> Path:
|
||||
"""Return the local path to a backup."""
|
||||
return self._backup_dir / f"{backup_id}.tar"
|
||||
"""Return the local path to an existing backup.
|
||||
|
||||
Raises BackupAgentError if the backup does not exist.
|
||||
"""
|
||||
try:
|
||||
return self._backups[backup_id][1]
|
||||
except KeyError as err:
|
||||
raise BackupNotFound(f"Backup {backup_id} does not exist") from err
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return self._backup_dir / suggested_filename(backup)
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Delete a backup file."""
|
||||
if await self.async_get_backup(backup_id) is None:
|
||||
return
|
||||
if not self._loaded_backups:
|
||||
await self._load_backups()
|
||||
|
||||
backup_path = self.get_backup_path(backup_id)
|
||||
try:
|
||||
backup_path = self.get_backup_path(backup_id)
|
||||
except BackupNotFound:
|
||||
return
|
||||
await self._hass.async_add_executor_job(backup_path.unlink, True)
|
||||
LOGGER.debug("Deleted backup located at %s", backup_path)
|
||||
self._backups.pop(backup_id)
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field, replace
|
||||
import datetime as dt
|
||||
from datetime import datetime, timedelta
|
||||
@@ -252,7 +250,7 @@ class RetentionConfig:
|
||||
"""Delete backups older than days."""
|
||||
self._schedule_next(manager)
|
||||
|
||||
def _backups_filter(
|
||||
def _delete_filter(
|
||||
backups: dict[str, ManagerBackup],
|
||||
) -> dict[str, ManagerBackup]:
|
||||
"""Return backups older than days to delete."""
|
||||
@@ -269,7 +267,9 @@ class RetentionConfig:
|
||||
< now
|
||||
}
|
||||
|
||||
await _delete_filtered_backups(manager, _backups_filter)
|
||||
await manager.async_delete_filtered_backups(
|
||||
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
|
||||
)
|
||||
|
||||
manager.remove_next_delete_event = async_call_later(
|
||||
manager.hass, timedelta(days=1), _delete_backups
|
||||
@@ -521,74 +521,21 @@ class CreateBackupParametersDict(TypedDict, total=False):
|
||||
password: str | None
|
||||
|
||||
|
||||
async def _delete_filtered_backups(
|
||||
manager: BackupManager,
|
||||
backup_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
|
||||
) -> None:
|
||||
"""Delete backups parsed with a filter.
|
||||
|
||||
:param manager: The backup manager.
|
||||
:param backup_filter: A filter that should return the backups to delete.
|
||||
"""
|
||||
backups, get_agent_errors = await manager.async_get_backups()
|
||||
if get_agent_errors:
|
||||
LOGGER.debug(
|
||||
"Error getting backups; continuing anyway: %s",
|
||||
get_agent_errors,
|
||||
)
|
||||
|
||||
# only delete backups that are created with the saved automatic settings
|
||||
backups = {
|
||||
def _automatic_backups_filter(
|
||||
backups: dict[str, ManagerBackup],
|
||||
) -> dict[str, ManagerBackup]:
|
||||
"""Return automatic backups."""
|
||||
return {
|
||||
backup_id: backup
|
||||
for backup_id, backup in backups.items()
|
||||
if backup.with_automatic_settings
|
||||
}
|
||||
|
||||
LOGGER.debug("Total automatic backups: %s", backups)
|
||||
|
||||
filtered_backups = backup_filter(backups)
|
||||
|
||||
if not filtered_backups:
|
||||
return
|
||||
|
||||
# always delete oldest backup first
|
||||
filtered_backups = dict(
|
||||
sorted(
|
||||
filtered_backups.items(),
|
||||
key=lambda backup_item: backup_item[1].date,
|
||||
)
|
||||
)
|
||||
|
||||
if len(filtered_backups) >= len(backups):
|
||||
# Never delete the last backup.
|
||||
last_backup = filtered_backups.popitem()
|
||||
LOGGER.debug("Keeping the last backup: %s", last_backup)
|
||||
|
||||
LOGGER.debug("Backups to delete: %s", filtered_backups)
|
||||
|
||||
if not filtered_backups:
|
||||
return
|
||||
|
||||
backup_ids = list(filtered_backups)
|
||||
delete_results = await asyncio.gather(
|
||||
*(manager.async_delete_backup(backup_id) for backup_id in filtered_backups)
|
||||
)
|
||||
agent_errors = {
|
||||
backup_id: error
|
||||
for backup_id, error in zip(backup_ids, delete_results, strict=True)
|
||||
if error
|
||||
}
|
||||
if agent_errors:
|
||||
LOGGER.error(
|
||||
"Error deleting old copies: %s",
|
||||
agent_errors,
|
||||
)
|
||||
|
||||
|
||||
async def delete_backups_exceeding_configured_count(manager: BackupManager) -> None:
|
||||
"""Delete backups exceeding the configured retention count."""
|
||||
|
||||
def _backups_filter(
|
||||
def _delete_filter(
|
||||
backups: dict[str, ManagerBackup],
|
||||
) -> dict[str, ManagerBackup]:
|
||||
"""Return oldest backups more numerous than copies to delete."""
|
||||
@@ -603,4 +550,6 @@ async def delete_backups_exceeding_configured_count(manager: BackupManager) -> N
|
||||
)[: max(len(backups) - manager.config.data.retention.copies, 0)]
|
||||
)
|
||||
|
||||
await _delete_filtered_backups(manager, _backups_filter)
|
||||
await manager.async_delete_filtered_backups(
|
||||
include_filter=_automatic_backups_filter, delete_filter=_delete_filter
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from . import util
|
||||
from .agent import BackupAgent
|
||||
from .const import DATA_MANAGER
|
||||
from .manager import BackupManager
|
||||
from .models import BackupNotFound
|
||||
|
||||
|
||||
@callback
|
||||
@@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView):
|
||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
||||
}
|
||||
|
||||
if not password or not backup.protected:
|
||||
return await self._send_backup_no_password(
|
||||
request, headers, backup_id, agent_id, agent, manager
|
||||
try:
|
||||
if not password or not backup.protected:
|
||||
return await self._send_backup_no_password(
|
||||
request, headers, backup_id, agent_id, agent, manager
|
||||
)
|
||||
return await self._send_backup_with_password(
|
||||
hass, request, headers, backup_id, agent_id, password, agent, manager
|
||||
)
|
||||
return await self._send_backup_with_password(
|
||||
hass, request, headers, backup_id, agent_id, password, agent, manager
|
||||
)
|
||||
except BackupNotFound:
|
||||
return Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
async def _send_backup_no_password(
|
||||
self,
|
||||
|
||||
@@ -50,7 +50,14 @@ from .const import (
|
||||
EXCLUDE_FROM_BACKUP,
|
||||
LOGGER,
|
||||
)
|
||||
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
|
||||
from .models import (
|
||||
AgentBackup,
|
||||
BackupError,
|
||||
BackupManagerError,
|
||||
BackupReaderWriterError,
|
||||
BaseBackup,
|
||||
Folder,
|
||||
)
|
||||
from .store import BackupStore
|
||||
from .util import (
|
||||
AsyncIteratorReader,
|
||||
@@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC):
|
||||
"""Get restore events after core restart."""
|
||||
|
||||
|
||||
class BackupReaderWriterError(BackupError):
|
||||
"""Backup reader/writer error."""
|
||||
|
||||
error_code = "backup_reader_writer_error"
|
||||
|
||||
|
||||
class IncorrectPasswordError(BackupReaderWriterError):
|
||||
"""Raised when the password is incorrect."""
|
||||
|
||||
@@ -685,6 +686,70 @@ class BackupManager:
|
||||
|
||||
return agent_errors
|
||||
|
||||
async def async_delete_filtered_backups(
|
||||
self,
|
||||
*,
|
||||
include_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
|
||||
delete_filter: Callable[[dict[str, ManagerBackup]], dict[str, ManagerBackup]],
|
||||
) -> None:
|
||||
"""Delete backups parsed with a filter.
|
||||
|
||||
:param include_filter: A filter that should return the backups to consider for
|
||||
deletion. Note: The newest of the backups returned by include_filter will
|
||||
unconditionally be kept, even if delete_filter returns all backups.
|
||||
:param delete_filter: A filter that should return the backups to delete.
|
||||
"""
|
||||
backups, get_agent_errors = await self.async_get_backups()
|
||||
if get_agent_errors:
|
||||
LOGGER.debug(
|
||||
"Error getting backups; continuing anyway: %s",
|
||||
get_agent_errors,
|
||||
)
|
||||
|
||||
# Run the include filter first to ensure we only consider backups that
|
||||
# should be included in the deletion process.
|
||||
backups = include_filter(backups)
|
||||
|
||||
LOGGER.debug("Total automatic backups: %s", backups)
|
||||
|
||||
backups_to_delete = delete_filter(backups)
|
||||
|
||||
if not backups_to_delete:
|
||||
return
|
||||
|
||||
# always delete oldest backup first
|
||||
backups_to_delete = dict(
|
||||
sorted(
|
||||
backups_to_delete.items(),
|
||||
key=lambda backup_item: backup_item[1].date,
|
||||
)
|
||||
)
|
||||
|
||||
if len(backups_to_delete) >= len(backups):
|
||||
# Never delete the last backup.
|
||||
last_backup = backups_to_delete.popitem()
|
||||
LOGGER.debug("Keeping the last backup: %s", last_backup)
|
||||
|
||||
LOGGER.debug("Backups to delete: %s", backups_to_delete)
|
||||
|
||||
if not backups_to_delete:
|
||||
return
|
||||
|
||||
backup_ids = list(backups_to_delete)
|
||||
delete_results = await asyncio.gather(
|
||||
*(self.async_delete_backup(backup_id) for backup_id in backups_to_delete)
|
||||
)
|
||||
agent_errors = {
|
||||
backup_id: error
|
||||
for backup_id, error in zip(backup_ids, delete_results, strict=True)
|
||||
if error
|
||||
}
|
||||
if agent_errors:
|
||||
LOGGER.error(
|
||||
"Error deleting old copies: %s",
|
||||
agent_errors,
|
||||
)
|
||||
|
||||
async def async_receive_backup(
|
||||
self,
|
||||
*,
|
||||
@@ -898,7 +963,7 @@ class BackupManager:
|
||||
)
|
||||
|
||||
backup_name = (
|
||||
name
|
||||
(name if name is None else name.strip())
|
||||
or f"{'Automatic' if with_automatic_settings else 'Custom'} backup {HAVERSION}"
|
||||
)
|
||||
extra_metadata = extra_metadata or {}
|
||||
@@ -1166,7 +1231,11 @@ class BackupManager:
|
||||
learn_more_url="homeassistant://config/backup",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="automatic_backup_failed_upload_agents",
|
||||
translation_placeholders={"failed_agents": ", ".join(agent_errors)},
|
||||
translation_placeholders={
|
||||
"failed_agents": ", ".join(
|
||||
self.backup_agents[agent_id].name for agent_id in agent_errors
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
async def async_can_decrypt_on_download(
|
||||
@@ -1346,10 +1415,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
if agent_config and not agent_config.protected:
|
||||
password = None
|
||||
|
||||
backup = AgentBackup(
|
||||
addons=[],
|
||||
backup_id=backup_id,
|
||||
database_included=include_database,
|
||||
date=date_str,
|
||||
extra_metadata=extra_metadata,
|
||||
folders=[],
|
||||
homeassistant_included=True,
|
||||
homeassistant_version=HAVERSION,
|
||||
name=backup_name,
|
||||
protected=password is not None,
|
||||
size=0,
|
||||
)
|
||||
|
||||
local_agent_tar_file_path = None
|
||||
if self._local_agent_id in agent_ids:
|
||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||
local_agent_tar_file_path = local_agent.get_backup_path(backup_id)
|
||||
local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
|
||||
|
||||
on_progress(
|
||||
CreateBackupEvent(
|
||||
@@ -1391,19 +1474,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
# ValueError from json_bytes
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
else:
|
||||
backup = AgentBackup(
|
||||
addons=[],
|
||||
backup_id=backup_id,
|
||||
database_included=include_database,
|
||||
date=date_str,
|
||||
extra_metadata=extra_metadata,
|
||||
folders=[],
|
||||
homeassistant_included=True,
|
||||
homeassistant_version=HAVERSION,
|
||||
name=backup_name,
|
||||
protected=password is not None,
|
||||
size=size_in_bytes,
|
||||
)
|
||||
backup = replace(backup, size=size_in_bytes)
|
||||
|
||||
async_add_executor_job = self._hass.async_add_executor_job
|
||||
|
||||
@@ -1517,7 +1588,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
manager = self._hass.data[DATA_MANAGER]
|
||||
if self._local_agent_id in agent_ids:
|
||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||
tar_file_path = local_agent.get_backup_path(backup.backup_id)
|
||||
tar_file_path = local_agent.get_new_backup_path(backup)
|
||||
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
|
||||
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
|
||||
else:
|
||||
|
||||
@@ -41,12 +41,6 @@ class BaseBackup:
|
||||
homeassistant_version: str | None # None if homeassistant_included is False
|
||||
name: str
|
||||
|
||||
def as_frontend_json(self) -> dict:
|
||||
"""Return a dict representation of this backup for sending to frontend."""
|
||||
return {
|
||||
key: val for key, val in asdict(self).items() if key != "extra_metadata"
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AgentBackup(BaseBackup):
|
||||
@@ -83,7 +77,25 @@ class BackupError(HomeAssistantError):
|
||||
error_code = "unknown"
|
||||
|
||||
|
||||
class BackupAgentError(BackupError):
|
||||
"""Base class for backup agent errors."""
|
||||
|
||||
error_code = "backup_agent_error"
|
||||
|
||||
|
||||
class BackupManagerError(BackupError):
|
||||
"""Backup manager error."""
|
||||
|
||||
error_code = "backup_manager_error"
|
||||
|
||||
|
||||
class BackupReaderWriterError(BackupError):
|
||||
"""Backup reader/writer error."""
|
||||
|
||||
error_code = "backup_reader_writer_error"
|
||||
|
||||
|
||||
class BackupNotFound(BackupAgentError, BackupManagerError):
|
||||
"""Raised when a backup is not found."""
|
||||
|
||||
error_code = "backup_not_found"
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 2
|
||||
STORAGE_VERSION_MINOR = 3
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
"""Migrate to the new version."""
|
||||
data = old_data
|
||||
if old_major_version == 1:
|
||||
if old_minor_version < 2:
|
||||
# Version 1.2 adds per agent settings, configurable backup time
|
||||
if old_minor_version < 3:
|
||||
# Version 1.2 bumped to 1.3 because 1.2 was changed several
|
||||
# times during development.
|
||||
# Version 1.3 adds per agent settings, configurable backup time
|
||||
# and custom days
|
||||
data["config"]["agents"] = {}
|
||||
data["config"]["schedule"]["time"] = None
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from concurrent.futures import CancelledError, Future
|
||||
import copy
|
||||
from dataclasses import dataclass, replace
|
||||
from io import BytesIO
|
||||
@@ -12,6 +13,7 @@ import os
|
||||
from pathlib import Path, PurePath
|
||||
from queue import SimpleQueue
|
||||
import tarfile
|
||||
import threading
|
||||
from typing import IO, Any, Self, cast
|
||||
|
||||
import aiohttp
|
||||
@@ -20,8 +22,8 @@ from securetar import SecureTarError, SecureTarFile, SecureTarReadError
|
||||
from homeassistant.backup_restore import password_to_key
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
from homeassistant.util.thread import ThreadWithException
|
||||
|
||||
from .const import BUF_SIZE, LOGGER
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
@@ -117,6 +119,17 @@ def read_backup(backup_path: Path) -> AgentBackup:
|
||||
)
|
||||
|
||||
|
||||
def suggested_filename_from_name_date(name: str, date_str: str) -> str:
|
||||
"""Suggest a filename for the backup."""
|
||||
date = dt_util.parse_datetime(date_str, raise_on_error=True)
|
||||
return "_".join(f"{name} - {date.strftime('%Y-%m-%d %H.%M %S%f')}.tar".split())
|
||||
|
||||
|
||||
def suggested_filename(backup: AgentBackup) -> str:
|
||||
"""Suggest a filename for the backup."""
|
||||
return suggested_filename_from_name_date(backup.name, backup.date)
|
||||
|
||||
|
||||
def validate_password(path: Path, password: str | None) -> bool:
|
||||
"""Validate the password."""
|
||||
with tarfile.open(path, "r:", bufsize=BUF_SIZE) as backup_file:
|
||||
@@ -155,23 +168,38 @@ class AsyncIteratorReader:
|
||||
|
||||
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._hass = hass
|
||||
self._stream = stream
|
||||
self._buffer: bytes | None = None
|
||||
self._next_future: Future[bytes | None] | None = None
|
||||
self._pos: int = 0
|
||||
|
||||
async def _next(self) -> bytes | None:
|
||||
"""Get the next chunk from the iterator."""
|
||||
return await anext(self._stream, None)
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the reader."""
|
||||
self._aborted = True
|
||||
if self._next_future is not None:
|
||||
self._next_future.cancel()
|
||||
|
||||
def read(self, n: int = -1, /) -> bytes:
|
||||
"""Read data from the iterator."""
|
||||
result = bytearray()
|
||||
while n < 0 or len(result) < n:
|
||||
if not self._buffer:
|
||||
self._buffer = asyncio.run_coroutine_threadsafe(
|
||||
self._next_future = asyncio.run_coroutine_threadsafe(
|
||||
self._next(), self._hass.loop
|
||||
).result()
|
||||
)
|
||||
if self._aborted:
|
||||
self._next_future.cancel()
|
||||
raise AbortCipher
|
||||
try:
|
||||
self._buffer = self._next_future.result()
|
||||
except CancelledError as err:
|
||||
raise AbortCipher from err
|
||||
self._pos = 0
|
||||
if not self._buffer:
|
||||
# The stream is exhausted
|
||||
@@ -193,9 +221,11 @@ class AsyncIteratorWriter:
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the wrapper."""
|
||||
self._aborted = False
|
||||
self._hass = hass
|
||||
self._pos: int = 0
|
||||
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
||||
self._write_future: Future[bytes | None] | None = None
|
||||
|
||||
def __aiter__(self) -> Self:
|
||||
"""Return the iterator."""
|
||||
@@ -207,13 +237,28 @@ class AsyncIteratorWriter:
|
||||
return data
|
||||
raise StopAsyncIteration
|
||||
|
||||
def abort(self) -> None:
|
||||
"""Abort the writer."""
|
||||
self._aborted = True
|
||||
if self._write_future is not None:
|
||||
self._write_future.cancel()
|
||||
|
||||
def tell(self) -> int:
|
||||
"""Return the current position in the iterator."""
|
||||
return self._pos
|
||||
|
||||
def write(self, s: bytes, /) -> int:
|
||||
"""Write data to the iterator."""
|
||||
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
|
||||
self._write_future = asyncio.run_coroutine_threadsafe(
|
||||
self._queue.put(s), self._hass.loop
|
||||
)
|
||||
if self._aborted:
|
||||
self._write_future.cancel()
|
||||
raise AbortCipher
|
||||
try:
|
||||
self._write_future.result()
|
||||
except CancelledError as err:
|
||||
raise AbortCipher from err
|
||||
self._pos += len(s)
|
||||
return len(s)
|
||||
|
||||
@@ -403,7 +448,9 @@ def _encrypt_backup(
|
||||
class _CipherWorkerStatus:
|
||||
done: asyncio.Event
|
||||
error: Exception | None = None
|
||||
thread: ThreadWithException
|
||||
reader: AsyncIteratorReader
|
||||
thread: threading.Thread
|
||||
writer: AsyncIteratorWriter
|
||||
|
||||
|
||||
class _CipherBackupStreamer:
|
||||
@@ -456,11 +503,13 @@ class _CipherBackupStreamer:
|
||||
stream = await self._open_stream()
|
||||
reader = AsyncIteratorReader(self._hass, stream)
|
||||
writer = AsyncIteratorWriter(self._hass)
|
||||
worker = ThreadWithException(
|
||||
worker = threading.Thread(
|
||||
target=self._cipher_func,
|
||||
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
|
||||
)
|
||||
worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker)
|
||||
worker_status = _CipherWorkerStatus(
|
||||
done=asyncio.Event(), reader=reader, thread=worker, writer=writer
|
||||
)
|
||||
self._workers.append(worker_status)
|
||||
worker.start()
|
||||
return writer
|
||||
@@ -468,9 +517,8 @@ class _CipherBackupStreamer:
|
||||
async def wait(self) -> None:
|
||||
"""Wait for the worker threads to finish."""
|
||||
for worker in self._workers:
|
||||
if not worker.thread.is_alive():
|
||||
continue
|
||||
worker.thread.raise_exc(AbortCipher)
|
||||
worker.reader.abort()
|
||||
worker.writer.abort()
|
||||
await asyncio.gather(*(worker.done.wait() for worker in self._workers))
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ from .manager import (
|
||||
IncorrectPasswordError,
|
||||
ManagerStateEvent,
|
||||
)
|
||||
from .models import Folder
|
||||
from .models import BackupNotFound, Folder
|
||||
|
||||
|
||||
@callback
|
||||
@@ -57,7 +57,7 @@ async def handle_info(
|
||||
"agent_errors": {
|
||||
agent_id: str(err) for agent_id, err in agent_errors.items()
|
||||
},
|
||||
"backups": [backup.as_frontend_json() for backup in backups.values()],
|
||||
"backups": list(backups.values()),
|
||||
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
|
||||
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
|
||||
"last_non_idle_event": manager.last_non_idle_event,
|
||||
@@ -91,7 +91,7 @@ async def handle_details(
|
||||
"agent_errors": {
|
||||
agent_id: str(err) for agent_id, err in agent_errors.items()
|
||||
},
|
||||
"backup": backup.as_frontend_json() if backup else None,
|
||||
"backup": backup,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -151,6 +151,8 @@ async def handle_restore(
|
||||
restore_folders=msg.get("restore_folders"),
|
||||
restore_homeassistant=msg["restore_homeassistant"],
|
||||
)
|
||||
except BackupNotFound:
|
||||
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
|
||||
except IncorrectPasswordError:
|
||||
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
|
||||
else:
|
||||
@@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download(
|
||||
agent_id=msg["agent_id"],
|
||||
password=msg.get("password"),
|
||||
)
|
||||
except BackupNotFound:
|
||||
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
|
||||
except IncorrectPasswordError:
|
||||
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
|
||||
except DecryptOnDowloadNotSupported:
|
||||
@@ -199,7 +203,7 @@ async def handle_can_decrypt_on_download(
|
||||
vol.Optional("include_database", default=True): bool,
|
||||
vol.Optional("include_folders"): [vol.Coerce(Folder)],
|
||||
vol.Optional("include_homeassistant", default=True): bool,
|
||||
vol.Optional("name"): str,
|
||||
vol.Optional("name"): vol.Any(str, None),
|
||||
vol.Optional("password"): vol.Any(str, None),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -19,6 +19,8 @@ from .const import (
|
||||
)
|
||||
from .entity import BangOlufsenEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
"services": {
|
||||
"join": {
|
||||
"name": "Join",
|
||||
"description": "Group player together.",
|
||||
"description": "Groups players together under a single master speaker.",
|
||||
"fields": {
|
||||
"master": {
|
||||
"name": "Master",
|
||||
@@ -36,23 +36,23 @@
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Name of entity that will coordinate the grouping. Platform dependent."
|
||||
"description": "Name of entity that will group to master speaker. Platform dependent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"unjoin": {
|
||||
"name": "Unjoin",
|
||||
"description": "Unjoin the player from a group.",
|
||||
"description": "Separates a player from a group.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
"description": "Name of entity that will be unjoined from their group. Platform dependent."
|
||||
"description": "Name of entity that will be separated from their group. Platform dependent."
|
||||
}
|
||||
}
|
||||
},
|
||||
"set_sleep_timer": {
|
||||
"name": "Set sleep timer",
|
||||
"description": "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0.",
|
||||
"description": "Sets a Bluesound timer that will turn off the speaker. It will increase in steps: 15, 30, 45, 60, 90, 0.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"clear_sleep_timer": {
|
||||
"name": "Clear sleep timer",
|
||||
"description": "Clear a Bluesound timer.",
|
||||
"description": "Clears a Bluesound timer.",
|
||||
"fields": {
|
||||
"entity_id": {
|
||||
"name": "Entity",
|
||||
|
||||
@@ -80,6 +80,7 @@ from .const import (
|
||||
CONF_DETAILS,
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
CONF_SOURCE_MODEL,
|
||||
DOMAIN,
|
||||
@@ -297,7 +298,12 @@ async def async_discover_adapters(
|
||||
|
||||
|
||||
async def async_update_device(
|
||||
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
adapter: str,
|
||||
details: AdapterDetails,
|
||||
via_device_domain: str | None = None,
|
||||
via_device_id: str | None = None,
|
||||
) -> None:
|
||||
"""Update device registry entry.
|
||||
|
||||
@@ -306,7 +312,8 @@ async def async_update_device(
|
||||
update the device with the new location so they can
|
||||
figure out where the adapter is.
|
||||
"""
|
||||
dr.async_get(hass).async_get_or_create(
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=entry.entry_id,
|
||||
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
|
||||
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
|
||||
@@ -315,6 +322,10 @@ async def async_update_device(
|
||||
sw_version=details.get(ADAPTER_SW_VERSION),
|
||||
hw_version=details.get(ADAPTER_HW_VERSION),
|
||||
)
|
||||
if via_device_id:
|
||||
device_registry.async_update_device(
|
||||
device_entry.id, via_device_id=via_device_id
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -349,6 +360,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
entry,
|
||||
source_entry.title,
|
||||
details,
|
||||
source_domain,
|
||||
entry.data.get(CONF_SOURCE_DEVICE_ID),
|
||||
)
|
||||
return True
|
||||
manager = _get_manager(hass)
|
||||
|
||||
@@ -181,10 +181,16 @@ def async_register_scanner(
|
||||
source_domain: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_config_entry_id: str | None = None,
|
||||
source_device_id: str | None = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register a BleakScanner."""
|
||||
return _get_manager(hass).async_register_hass_scanner(
|
||||
scanner, connection_slots, source_domain, source_model, source_config_entry_id
|
||||
scanner,
|
||||
connection_slots,
|
||||
source_domain,
|
||||
source_model,
|
||||
source_config_entry_id,
|
||||
source_device_id,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from .const import (
|
||||
CONF_PASSIVE,
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
CONF_SOURCE_MODEL,
|
||||
DOMAIN,
|
||||
@@ -194,6 +195,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
|
||||
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
|
||||
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
|
||||
}
|
||||
self._abort_if_unique_id_configured(updates=data)
|
||||
manager = get_manager()
|
||||
|
||||
@@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
|
||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||
CONF_SOURCE_MODEL: Final = "source_model"
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||
|
||||
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
|
||||
|
||||
SOURCE_LOCAL: Final = "local"
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from .const import (
|
||||
CONF_SOURCE,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||
CONF_SOURCE_DEVICE_ID,
|
||||
CONF_SOURCE_DOMAIN,
|
||||
CONF_SOURCE_MODEL,
|
||||
DOMAIN,
|
||||
@@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
source_domain: str | None = None,
|
||||
source_model: str | None = None,
|
||||
source_config_entry_id: str | None = None,
|
||||
source_device_id: str | None = None,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Register a scanner."""
|
||||
cancel = self.async_register_scanner(scanner, connection_slots)
|
||||
@@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
isinstance(scanner, BaseHaRemoteScanner)
|
||||
and source_domain
|
||||
and source_config_entry_id
|
||||
and not self.hass.config_entries.async_entry_for_domain_unique_id(
|
||||
DOMAIN, scanner.source
|
||||
)
|
||||
):
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.flow.async_init(
|
||||
@@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
||||
CONF_SOURCE_DOMAIN: source_domain,
|
||||
CONF_SOURCE_MODEL: source_model,
|
||||
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
|
||||
CONF_SOURCE_DEVICE_ID: source_device_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
"bleak-retry-connector==3.8.0",
|
||||
"bluetooth-adapters==0.21.1",
|
||||
"bluetooth-auto-recovery==1.4.2",
|
||||
"bluetooth-data-tools==1.22.0",
|
||||
"dbus-fast==2.30.2",
|
||||
"habluetooth==3.14.0"
|
||||
"bluetooth-data-tools==1.23.3",
|
||||
"dbus-fast==2.32.0",
|
||||
"habluetooth==3.21.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,10 @@ def async_load_history_from_system(
|
||||
now_monotonic = monotonic_time_coarse()
|
||||
connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
adapter_to_source_address = {
|
||||
adapter: details[ADAPTER_ADDRESS]
|
||||
for adapter, details in adapters.adapters.items()
|
||||
}
|
||||
|
||||
# Restore local adapters
|
||||
for address, history in adapters.history.items():
|
||||
@@ -50,7 +54,11 @@ def async_load_history_from_system(
|
||||
BluetoothServiceInfoBleak.from_device_and_advertisement_data(
|
||||
history.device,
|
||||
history.advertisement_data,
|
||||
history.source,
|
||||
# history.source is really the adapter name
|
||||
# for historical compatibility since BlueZ
|
||||
# does not know the MAC address of the adapter
|
||||
# so we need to convert it to the source address (MAC)
|
||||
adapter_to_source_address.get(history.source, history.source),
|
||||
now_monotonic,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_EMAIL
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -39,6 +40,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
|
||||
config_entry: ConfigEntry
|
||||
user_settings: BringUserSettingsResponse
|
||||
lists: list[BringList]
|
||||
|
||||
def __init__(self, hass: HomeAssistant, bring: Bring) -> None:
|
||||
"""Initialize the Bring data coordinator."""
|
||||
@@ -49,10 +51,13 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
update_interval=timedelta(seconds=90),
|
||||
)
|
||||
self.bring = bring
|
||||
self.previous_lists: set[str] = set()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, BringData]:
|
||||
"""Fetch the latest data from bring."""
|
||||
|
||||
try:
|
||||
lists_response = await self.bring.load_lists()
|
||||
self.lists = (await self.bring.load_lists()).lists
|
||||
except BringRequestException as e:
|
||||
raise UpdateFailed("Unable to connect and retrieve data from bring") from e
|
||||
except BringParseException as e:
|
||||
@@ -72,8 +77,14 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
) from exc
|
||||
return self.data
|
||||
|
||||
if self.previous_lists - (
|
||||
current_lists := {lst.listUuid for lst in self.lists}
|
||||
):
|
||||
self._purge_deleted_lists()
|
||||
self.previous_lists = current_lists
|
||||
|
||||
list_dict: dict[str, BringData] = {}
|
||||
for lst in lists_response.lists:
|
||||
for lst in self.lists:
|
||||
if (ctx := set(self.async_contexts())) and lst.listUuid not in ctx:
|
||||
continue
|
||||
try:
|
||||
@@ -95,6 +106,7 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
try:
|
||||
await self.bring.login()
|
||||
self.user_settings = await self.bring.get_all_user_settings()
|
||||
self.lists = (await self.bring.load_lists()).lists
|
||||
except BringRequestException as e:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -111,3 +123,21 @@ class BringDataUpdateCoordinator(DataUpdateCoordinator[dict[str, BringData]]):
|
||||
translation_key="setup_authentication_exception",
|
||||
translation_placeholders={CONF_EMAIL: self.bring.mail},
|
||||
) from e
|
||||
self._purge_deleted_lists()
|
||||
|
||||
def _purge_deleted_lists(self) -> None:
|
||||
"""Purge device entries of deleted lists."""
|
||||
|
||||
device_reg = dr.async_get(self.hass)
|
||||
identifiers = {
|
||||
(DOMAIN, f"{self.config_entry.unique_id}_{lst.listUuid}")
|
||||
for lst in self.lists
|
||||
}
|
||||
for device in dr.async_entries_for_config_entry(
|
||||
device_reg, self.config_entry.entry_id
|
||||
):
|
||||
if not set(device.identifiers) & identifiers:
|
||||
_LOGGER.debug("Removing obsolete device entry %s", device.name)
|
||||
device_reg.async_update_device(
|
||||
device.id, remove_config_entry_id=self.config_entry.entry_id
|
||||
)
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from bring_api.types import BringList
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import BringData, BringDataUpdateCoordinator
|
||||
from .coordinator import BringDataUpdateCoordinator
|
||||
|
||||
|
||||
class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
|
||||
@@ -17,20 +19,20 @@ class BringBaseEntity(CoordinatorEntity[BringDataUpdateCoordinator]):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BringDataUpdateCoordinator,
|
||||
bring_list: BringData,
|
||||
bring_list: BringList,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, bring_list.lst.listUuid)
|
||||
super().__init__(coordinator, bring_list.listUuid)
|
||||
|
||||
self._list_uuid = bring_list.lst.listUuid
|
||||
self._list_uuid = bring_list.listUuid
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
name=bring_list.lst.name,
|
||||
name=bring_list.name,
|
||||
identifiers={
|
||||
(DOMAIN, f"{coordinator.config_entry.unique_id}_{self._list_uuid}")
|
||||
},
|
||||
manufacturer="Bring! Labs AG",
|
||||
model="Bring! Grocery Shopping List",
|
||||
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.data.keys()).index(self._list_uuid)}",
|
||||
configuration_url=f"https://web.getbring.com/app/lists/{list(self.coordinator.lists).index(bring_list)}",
|
||||
)
|
||||
|
||||
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -65,7 +65,7 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
no repairs
|
||||
stale-devices: todo
|
||||
stale-devices: done
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
|
||||
@@ -8,6 +8,7 @@ from enum import StrEnum
|
||||
|
||||
from bring_api import BringUserSettingsResponse
|
||||
from bring_api.const import BRING_SUPPORTED_LOCALES
|
||||
from bring_api.types import BringList
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -15,7 +16,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
@@ -90,16 +91,28 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the sensor platform."""
|
||||
coordinator = config_entry.runtime_data
|
||||
lists_added: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
BringSensorEntity(
|
||||
coordinator,
|
||||
bring_list,
|
||||
description,
|
||||
)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
for bring_list in coordinator.data.values()
|
||||
)
|
||||
@callback
|
||||
def add_entities() -> None:
|
||||
"""Add sensor entities."""
|
||||
nonlocal lists_added
|
||||
|
||||
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
|
||||
async_add_entities(
|
||||
BringSensorEntity(
|
||||
coordinator,
|
||||
bring_list,
|
||||
description,
|
||||
)
|
||||
for description in SENSOR_DESCRIPTIONS
|
||||
for bring_list in coordinator.lists
|
||||
if bring_list.listUuid in new_lists
|
||||
)
|
||||
lists_added |= new_lists
|
||||
|
||||
coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
|
||||
class BringSensorEntity(BringBaseEntity, SensorEntity):
|
||||
@@ -110,7 +123,7 @@ class BringSensorEntity(BringBaseEntity, SensorEntity):
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: BringDataUpdateCoordinator,
|
||||
bring_list: BringData,
|
||||
bring_list: BringList,
|
||||
entity_description: BringSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
|
||||
@@ -12,6 +12,7 @@ from bring_api import (
|
||||
BringNotificationType,
|
||||
BringRequestException,
|
||||
)
|
||||
from bring_api.types import BringList
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.todo import (
|
||||
@@ -20,7 +21,7 @@ from homeassistant.components.todo import (
|
||||
TodoListEntity,
|
||||
TodoListEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -45,14 +46,23 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the sensor from a config entry created in the integrations UI."""
|
||||
coordinator = config_entry.runtime_data
|
||||
lists_added: set[str] = set()
|
||||
|
||||
async_add_entities(
|
||||
BringTodoListEntity(
|
||||
coordinator,
|
||||
bring_list=bring_list,
|
||||
)
|
||||
for bring_list in coordinator.data.values()
|
||||
)
|
||||
@callback
|
||||
def add_entities() -> None:
|
||||
"""Add or remove todo list entities."""
|
||||
nonlocal lists_added
|
||||
|
||||
if new_lists := {lst.listUuid for lst in coordinator.lists} - lists_added:
|
||||
async_add_entities(
|
||||
BringTodoListEntity(coordinator, bring_list)
|
||||
for bring_list in coordinator.lists
|
||||
if bring_list.listUuid in new_lists
|
||||
)
|
||||
lists_added |= new_lists
|
||||
|
||||
coordinator.async_add_listener(add_entities)
|
||||
add_entities()
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
@@ -81,7 +91,7 @@ class BringTodoListEntity(BringBaseEntity, TodoListEntity):
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, coordinator: BringDataUpdateCoordinator, bring_list: BringData
|
||||
self, coordinator: BringDataUpdateCoordinator, bring_list: BringList
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator, bring_list)
|
||||
|
||||
@@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self._async_get_or_create_entry()
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["bthome-ble==3.9.1"]
|
||||
"requirements": ["bthome-ble==3.12.3"]
|
||||
}
|
||||
|
||||
@@ -67,6 +67,21 @@ SENSOR_DESCRIPTIONS = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
# Channel (-)
|
||||
(BTHomeExtendedSensorDeviceClass.CHANNEL, None): SensorEntityDescription(
|
||||
key=str(BTHomeExtendedSensorDeviceClass.CHANNEL),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Conductivity (µS/cm)
|
||||
(
|
||||
BTHomeSensorDeviceClass.CONDUCTIVITY,
|
||||
Units.CONDUCTIVITY,
|
||||
): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
||||
device_class=SensorDeviceClass.CONDUCTIVITY,
|
||||
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Count (-)
|
||||
(BTHomeSensorDeviceClass.COUNT, None): SensorEntityDescription(
|
||||
key=str(BTHomeSensorDeviceClass.COUNT),
|
||||
@@ -99,6 +114,12 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Directions (°)
|
||||
(BTHomeExtendedSensorDeviceClass.DIRECTION, Units.DEGREE): SensorEntityDescription(
|
||||
key=f"{BTHomeExtendedSensorDeviceClass.DIRECTION}_{Units.DEGREE}",
|
||||
native_unit_of_measurement=DEGREE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Distance (mm)
|
||||
(
|
||||
BTHomeSensorDeviceClass.DISTANCE,
|
||||
@@ -221,6 +242,16 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Precipitation (mm)
|
||||
(
|
||||
BTHomeExtendedSensorDeviceClass.PRECIPITATION,
|
||||
Units.LENGTH_MILLIMETERS,
|
||||
): SensorEntityDescription(
|
||||
key=f"{BTHomeExtendedSensorDeviceClass.PRECIPITATION}_{Units.LENGTH_MILLIMETERS}",
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
# Pressure (mbar)
|
||||
(BTHomeSensorDeviceClass.PRESSURE, Units.PRESSURE_MBAR): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.PRESSURE}_{Units.PRESSURE_MBAR}",
|
||||
@@ -357,16 +388,6 @@ SENSOR_DESCRIPTIONS = {
|
||||
native_unit_of_measurement=UnitOfVolume.LITERS,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
# Conductivity (µS/cm)
|
||||
(
|
||||
BTHomeSensorDeviceClass.CONDUCTIVITY,
|
||||
Units.CONDUCTIVITY,
|
||||
): SensorEntityDescription(
|
||||
key=f"{BTHomeSensorDeviceClass.CONDUCTIVITY}_{Units.CONDUCTIVITY}",
|
||||
device_class=SensorDeviceClass.CONDUCTIVITY,
|
||||
native_unit_of_measurement=UnitOfConductivity.MICROSIEMENS_PER_CM,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1175,12 +1175,17 @@ async def async_handle_snapshot_service(
|
||||
f"Cannot write `{snapshot_file}`, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
|
||||
)
|
||||
|
||||
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
|
||||
image = (
|
||||
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
|
||||
if camera.use_stream_for_stills
|
||||
else await camera.async_camera_image()
|
||||
)
|
||||
try:
|
||||
async with asyncio.timeout(CAMERA_IMAGE_TIMEOUT):
|
||||
image = (
|
||||
await _async_get_stream_image(camera, wait_for_next_keyframe=True)
|
||||
if camera.use_stream_for_stills
|
||||
else await camera.async_camera_image()
|
||||
)
|
||||
except TimeoutError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Unable to get snapshot: Timed out after {CAMERA_IMAGE_TIMEOUT} seconds"
|
||||
) from err
|
||||
|
||||
if image is None:
|
||||
return
|
||||
@@ -1194,7 +1199,7 @@ async def async_handle_snapshot_service(
|
||||
try:
|
||||
await hass.async_add_executor_job(_write_image, snapshot_file, image)
|
||||
except OSError as err:
|
||||
_LOGGER.error("Can't write image to file: %s", err)
|
||||
raise HomeAssistantError(f"Can't write image to file: {err}") from err
|
||||
|
||||
|
||||
async def async_handle_play_stream_service(
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
|
||||
from homeassistant.components.homeassistant import exposed_entities
|
||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.components.system_health import get_info as get_system_health_info
|
||||
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
hass.http.register_view(CloudRegisterView)
|
||||
hass.http.register_view(CloudResendConfirmView)
|
||||
hass.http.register_view(CloudForgotPasswordView)
|
||||
hass.http.register_view(DownloadSupportPackageView)
|
||||
|
||||
_CLOUD_ERRORS.update(
|
||||
{
|
||||
@@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
|
||||
return self.json_message("ok")
|
||||
|
||||
|
||||
class DownloadSupportPackageView(HomeAssistantView):
|
||||
"""Download support package view."""
|
||||
|
||||
url = "/api/cloud/support_package"
|
||||
name = "api:cloud:support_package"
|
||||
|
||||
def _generate_markdown(
|
||||
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
|
||||
) -> str:
|
||||
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
|
||||
if len(domain_info) == 0:
|
||||
return "No information available\n"
|
||||
|
||||
markdown = ""
|
||||
first = True
|
||||
for key, value in domain_info.items():
|
||||
markdown += f"{key} | {value}\n"
|
||||
if first:
|
||||
markdown += "--- | ---\n"
|
||||
first = False
|
||||
return markdown + "\n"
|
||||
|
||||
markdown = "## System Information\n\n"
|
||||
markdown += get_domain_table_markdown(hass_info)
|
||||
|
||||
for domain, domain_info in domains_info.items():
|
||||
domain_info_md = get_domain_table_markdown(domain_info)
|
||||
markdown += (
|
||||
f"<details><summary>{domain}</summary>\n\n"
|
||||
f"{domain_info_md}"
|
||||
"</details>\n\n"
|
||||
)
|
||||
|
||||
return markdown
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Download support package file."""
|
||||
|
||||
hass = request.app[KEY_HASS]
|
||||
domain_health = await get_system_health_info(hass)
|
||||
|
||||
hass_info = domain_health.pop("homeassistant", {})
|
||||
markdown = self._generate_markdown(hass_info, domain_health)
|
||||
|
||||
return web.Response(
|
||||
body=markdown,
|
||||
content_type="text/markdown",
|
||||
headers={
|
||||
"Content-Disposition": 'attachment; filename="support_package.md"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
|
||||
@websocket_api.async_response
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["hass_nabucasa"],
|
||||
"requirements": ["hass-nabucasa==0.88.1"],
|
||||
"requirements": ["hass-nabucasa==0.89.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -38,6 +38,156 @@ ATTR_GENDER = "gender"
|
||||
DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"}
|
||||
SUPPORT_LANGUAGES = list(TTS_VOICES)
|
||||
|
||||
DEFAULT_VOICES = {
|
||||
"af-ZA": "AdriNeural",
|
||||
"am-ET": "MekdesNeural",
|
||||
"ar-AE": "FatimaNeural",
|
||||
"ar-BH": "LailaNeural",
|
||||
"ar-DZ": "AminaNeural",
|
||||
"ar-EG": "SalmaNeural",
|
||||
"ar-IQ": "RanaNeural",
|
||||
"ar-JO": "SanaNeural",
|
||||
"ar-KW": "NouraNeural",
|
||||
"ar-LB": "LaylaNeural",
|
||||
"ar-LY": "ImanNeural",
|
||||
"ar-MA": "MounaNeural",
|
||||
"ar-OM": "AbdullahNeural",
|
||||
"ar-QA": "AmalNeural",
|
||||
"ar-SA": "ZariyahNeural",
|
||||
"ar-SY": "AmanyNeural",
|
||||
"ar-TN": "ReemNeural",
|
||||
"ar-YE": "MaryamNeural",
|
||||
"az-AZ": "BabekNeural",
|
||||
"bg-BG": "KalinaNeural",
|
||||
"bn-BD": "NabanitaNeural",
|
||||
"bn-IN": "TanishaaNeural",
|
||||
"bs-BA": "GoranNeural",
|
||||
"ca-ES": "JoanaNeural",
|
||||
"cs-CZ": "VlastaNeural",
|
||||
"cy-GB": "NiaNeural",
|
||||
"da-DK": "ChristelNeural",
|
||||
"de-AT": "IngridNeural",
|
||||
"de-CH": "LeniNeural",
|
||||
"de-DE": "KatjaNeural",
|
||||
"el-GR": "AthinaNeural",
|
||||
"en-AU": "NatashaNeural",
|
||||
"en-CA": "ClaraNeural",
|
||||
"en-GB": "LibbyNeural",
|
||||
"en-HK": "YanNeural",
|
||||
"en-IE": "EmilyNeural",
|
||||
"en-IN": "NeerjaNeural",
|
||||
"en-KE": "AsiliaNeural",
|
||||
"en-NG": "EzinneNeural",
|
||||
"en-NZ": "MollyNeural",
|
||||
"en-PH": "RosaNeural",
|
||||
"en-SG": "LunaNeural",
|
||||
"en-TZ": "ImaniNeural",
|
||||
"en-US": "JennyNeural",
|
||||
"en-ZA": "LeahNeural",
|
||||
"es-AR": "ElenaNeural",
|
||||
"es-BO": "SofiaNeural",
|
||||
"es-CL": "CatalinaNeural",
|
||||
"es-CO": "SalomeNeural",
|
||||
"es-CR": "MariaNeural",
|
||||
"es-CU": "BelkysNeural",
|
||||
"es-DO": "RamonaNeural",
|
||||
"es-EC": "AndreaNeural",
|
||||
"es-ES": "ElviraNeural",
|
||||
"es-GQ": "TeresaNeural",
|
||||
"es-GT": "MartaNeural",
|
||||
"es-HN": "KarlaNeural",
|
||||
"es-MX": "DaliaNeural",
|
||||
"es-NI": "YolandaNeural",
|
||||
"es-PA": "MargaritaNeural",
|
||||
"es-PE": "CamilaNeural",
|
||||
"es-PR": "KarinaNeural",
|
||||
"es-PY": "TaniaNeural",
|
||||
"es-SV": "LorenaNeural",
|
||||
"es-US": "PalomaNeural",
|
||||
"es-UY": "ValentinaNeural",
|
||||
"es-VE": "PaolaNeural",
|
||||
"et-EE": "AnuNeural",
|
||||
"eu-ES": "AinhoaNeural",
|
||||
"fa-IR": "DilaraNeural",
|
||||
"fi-FI": "SelmaNeural",
|
||||
"fil-PH": "BlessicaNeural",
|
||||
"fr-BE": "CharlineNeural",
|
||||
"fr-CA": "SylvieNeural",
|
||||
"fr-CH": "ArianeNeural",
|
||||
"fr-FR": "DeniseNeural",
|
||||
"ga-IE": "OrlaNeural",
|
||||
"gl-ES": "SabelaNeural",
|
||||
"gu-IN": "DhwaniNeural",
|
||||
"he-IL": "HilaNeural",
|
||||
"hi-IN": "SwaraNeural",
|
||||
"hr-HR": "GabrijelaNeural",
|
||||
"hu-HU": "NoemiNeural",
|
||||
"hy-AM": "AnahitNeural",
|
||||
"id-ID": "GadisNeural",
|
||||
"is-IS": "GudrunNeural",
|
||||
"it-IT": "ElsaNeural",
|
||||
"ja-JP": "NanamiNeural",
|
||||
"jv-ID": "SitiNeural",
|
||||
"ka-GE": "EkaNeural",
|
||||
"kk-KZ": "AigulNeural",
|
||||
"km-KH": "SreymomNeural",
|
||||
"kn-IN": "SapnaNeural",
|
||||
"ko-KR": "SunHiNeural",
|
||||
"lo-LA": "KeomanyNeural",
|
||||
"lt-LT": "OnaNeural",
|
||||
"lv-LV": "EveritaNeural",
|
||||
"mk-MK": "MarijaNeural",
|
||||
"ml-IN": "SobhanaNeural",
|
||||
"mn-MN": "BataaNeural",
|
||||
"mr-IN": "AarohiNeural",
|
||||
"ms-MY": "YasminNeural",
|
||||
"mt-MT": "GraceNeural",
|
||||
"my-MM": "NilarNeural",
|
||||
"nb-NO": "IselinNeural",
|
||||
"ne-NP": "HemkalaNeural",
|
||||
"nl-BE": "DenaNeural",
|
||||
"nl-NL": "ColetteNeural",
|
||||
"pl-PL": "AgnieszkaNeural",
|
||||
"ps-AF": "LatifaNeural",
|
||||
"pt-BR": "FranciscaNeural",
|
||||
"pt-PT": "RaquelNeural",
|
||||
"ro-RO": "AlinaNeural",
|
||||
"ru-RU": "SvetlanaNeural",
|
||||
"si-LK": "ThiliniNeural",
|
||||
"sk-SK": "ViktoriaNeural",
|
||||
"sl-SI": "PetraNeural",
|
||||
"so-SO": "UbaxNeural",
|
||||
"sq-AL": "AnilaNeural",
|
||||
"sr-RS": "SophieNeural",
|
||||
"su-ID": "TutiNeural",
|
||||
"sv-SE": "SofieNeural",
|
||||
"sw-KE": "ZuriNeural",
|
||||
"sw-TZ": "RehemaNeural",
|
||||
"ta-IN": "PallaviNeural",
|
||||
"ta-LK": "SaranyaNeural",
|
||||
"ta-MY": "KaniNeural",
|
||||
"ta-SG": "VenbaNeural",
|
||||
"te-IN": "ShrutiNeural",
|
||||
"th-TH": "AcharaNeural",
|
||||
"tr-TR": "EmelNeural",
|
||||
"uk-UA": "PolinaNeural",
|
||||
"ur-IN": "GulNeural",
|
||||
"ur-PK": "UzmaNeural",
|
||||
"uz-UZ": "MadinaNeural",
|
||||
"vi-VN": "HoaiMyNeural",
|
||||
"wuu-CN": "XiaotongNeural",
|
||||
"yue-CN": "XiaoMinNeural",
|
||||
"zh-CN": "XiaoxiaoNeural",
|
||||
"zh-CN-henan": "YundengNeural",
|
||||
"zh-CN-liaoning": "XiaobeiNeural",
|
||||
"zh-CN-shaanxi": "XiaoniNeural",
|
||||
"zh-CN-shandong": "YunxiangNeural",
|
||||
"zh-CN-sichuan": "YunxiNeural",
|
||||
"zh-HK": "HiuMaanNeural",
|
||||
"zh-TW": "HsiaoChenNeural",
|
||||
"zu-ZA": "ThandoNeural",
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -186,12 +336,13 @@ class CloudTTSEntity(TextToSpeechEntity):
|
||||
"""Load TTS from Home Assistant Cloud."""
|
||||
gender: Gender | str | None = options.get(ATTR_GENDER)
|
||||
gender = handle_deprecated_gender(self.hass, gender)
|
||||
original_voice: str | None = options.get(ATTR_VOICE)
|
||||
if original_voice is None and language == self._language:
|
||||
original_voice = self._voice
|
||||
original_voice: str = options.get(
|
||||
ATTR_VOICE,
|
||||
self._voice if language == self._language else DEFAULT_VOICES[language],
|
||||
)
|
||||
voice = handle_deprecated_voice(self.hass, original_voice)
|
||||
if voice not in TTS_VOICES[language]:
|
||||
default_voice = TTS_VOICES[language][0]
|
||||
default_voice = DEFAULT_VOICES[language]
|
||||
_LOGGER.debug(
|
||||
"Unsupported voice %s detected, falling back to default %s for %s",
|
||||
voice,
|
||||
@@ -266,12 +417,13 @@ class CloudProvider(Provider):
|
||||
assert self.hass is not None
|
||||
gender: Gender | str | None = options.get(ATTR_GENDER)
|
||||
gender = handle_deprecated_gender(self.hass, gender)
|
||||
original_voice: str | None = options.get(ATTR_VOICE)
|
||||
if original_voice is None and language == self._language:
|
||||
original_voice = self._voice
|
||||
original_voice: str = options.get(
|
||||
ATTR_VOICE,
|
||||
self._voice if language == self._language else DEFAULT_VOICES[language],
|
||||
)
|
||||
voice = handle_deprecated_voice(self.hass, original_voice)
|
||||
if voice not in TTS_VOICES[language]:
|
||||
default_voice = TTS_VOICES[language][0]
|
||||
default_voice = DEFAULT_VOICES[language]
|
||||
_LOGGER.debug(
|
||||
"Unsupported voice %s detected, falling back to default %s for %s",
|
||||
voice,
|
||||
|
||||
@@ -302,7 +302,8 @@ def config_entries_progress(
|
||||
[
|
||||
flw
|
||||
for flw in hass.config_entries.flow.async_progress()
|
||||
if flw["context"]["source"] != config_entries.SOURCE_USER
|
||||
if flw["context"]["source"]
|
||||
not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +30,16 @@ from .agent_manager import (
|
||||
async_get_agent,
|
||||
get_agent_manager,
|
||||
)
|
||||
from .chat_log import (
|
||||
AssistantContent,
|
||||
ChatLog,
|
||||
Content,
|
||||
ConverseError,
|
||||
SystemContent,
|
||||
ToolResultContent,
|
||||
UserContent,
|
||||
async_get_chat_log,
|
||||
)
|
||||
from .const import (
|
||||
ATTR_AGENT_ID,
|
||||
ATTR_CONVERSATION_ID,
|
||||
@@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
|
||||
from .entity import ConversationEntity
|
||||
from .http import async_setup as async_setup_conversation_http
|
||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
||||
from .session import (
|
||||
ChatSession,
|
||||
Content,
|
||||
ConverseError,
|
||||
NativeContent,
|
||||
async_get_chat_session,
|
||||
)
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"HOME_ASSISTANT_AGENT",
|
||||
"OLD_HOME_ASSISTANT_AGENT",
|
||||
"ChatSession",
|
||||
"AssistantContent",
|
||||
"ChatLog",
|
||||
"Content",
|
||||
"ConversationEntity",
|
||||
"ConversationEntityFeature",
|
||||
@@ -69,11 +73,13 @@ __all__ = [
|
||||
"ConversationResult",
|
||||
"ConversationTraceEventType",
|
||||
"ConverseError",
|
||||
"NativeContent",
|
||||
"SystemContent",
|
||||
"ToolResultContent",
|
||||
"UserContent",
|
||||
"async_conversation_trace_append",
|
||||
"async_converse",
|
||||
"async_get_agent_info",
|
||||
"async_get_chat_session",
|
||||
"async_get_chat_log",
|
||||
"async_set_agent",
|
||||
"async_setup",
|
||||
"async_unset_agent",
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Conversation history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field, replace
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import chat_session, intent, llm, template
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_HISTORY: HassKey[dict[str, ChatLog]] = HassKey("conversation_chat_log")
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def async_get_chat_log(
|
||||
hass: HomeAssistant,
|
||||
session: chat_session.ChatSession,
|
||||
user_input: ConversationInput | None = None,
|
||||
) -> Generator[ChatLog]:
|
||||
"""Return chat log for a specific chat session."""
|
||||
all_history = hass.data.get(DATA_CHAT_HISTORY)
|
||||
if all_history is None:
|
||||
all_history = {}
|
||||
hass.data[DATA_CHAT_HISTORY] = all_history
|
||||
|
||||
history = all_history.get(session.conversation_id)
|
||||
|
||||
if history:
|
||||
history = replace(history, content=history.content.copy())
|
||||
else:
|
||||
history = ChatLog(hass, session.conversation_id)
|
||||
|
||||
@callback
|
||||
def do_cleanup() -> None:
|
||||
"""Handle cleanup."""
|
||||
all_history.pop(session.conversation_id)
|
||||
|
||||
session.async_on_cleanup(do_cleanup)
|
||||
|
||||
if user_input is not None:
|
||||
history.async_add_user_content(UserContent(content=user_input.text))
|
||||
|
||||
last_message = history.content[-1]
|
||||
|
||||
yield history
|
||||
|
||||
if history.content[-1] is last_message:
|
||||
LOGGER.debug(
|
||||
"History opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
return
|
||||
|
||||
all_history[session.conversation_id] = history
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
|
||||
Will not be stored in the history.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, message: str, conversation_id: str, response: intent.IntentResponse
|
||||
) -> None:
|
||||
"""Initialize the error."""
|
||||
super().__init__(message)
|
||||
self.conversation_id = conversation_id
|
||||
self.response = response
|
||||
|
||||
def as_conversation_result(self) -> ConversationResult:
|
||||
"""Return the error as a conversation result."""
|
||||
return ConversationResult(
|
||||
response=self.response,
|
||||
conversation_id=self.conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SystemContent:
|
||||
"""Base class for chat messages."""
|
||||
|
||||
role: str = field(init=False, default="system")
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UserContent:
|
||||
"""Assistant content."""
|
||||
|
||||
role: str = field(init=False, default="user")
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AssistantContent:
|
||||
"""Assistant content."""
|
||||
|
||||
role: str = field(init=False, default="assistant")
|
||||
agent_id: str
|
||||
content: str
|
||||
tool_calls: list[llm.ToolInput] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolResultContent:
|
||||
"""Tool result content."""
|
||||
|
||||
role: str = field(init=False, default="tool_result")
|
||||
agent_id: str
|
||||
tool_call_id: str
|
||||
tool_name: str
|
||||
tool_result: JsonObjectType
|
||||
|
||||
|
||||
Content = SystemContent | UserContent | AssistantContent | ToolResultContent
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatLog:
|
||||
"""Class holding the chat history of a specific conversation."""
|
||||
|
||||
hass: HomeAssistant
|
||||
conversation_id: str
|
||||
content: list[Content] = field(default_factory=lambda: [SystemContent(content="")])
|
||||
extra_system_prompt: str | None = None
|
||||
llm_api: llm.APIInstance | None = None
|
||||
|
||||
@callback
|
||||
def async_add_user_content(self, content: UserContent) -> None:
|
||||
"""Add user content to the log."""
|
||||
self.content.append(content)
|
||||
|
||||
@callback
|
||||
def async_add_assistant_content_without_tools(
|
||||
self, content: AssistantContent
|
||||
) -> None:
|
||||
"""Add assistant content to the log."""
|
||||
if content.tool_calls is not None:
|
||||
raise ValueError("Tool calls not allowed")
|
||||
self.content.append(content)
|
||||
|
||||
async def async_add_assistant_content(
|
||||
self, content: AssistantContent
|
||||
) -> AsyncGenerator[ToolResultContent]:
|
||||
"""Add assistant content."""
|
||||
self.content.append(content)
|
||||
|
||||
if content.tool_calls is None:
|
||||
return
|
||||
|
||||
if self.llm_api is None:
|
||||
raise ValueError("No LLM API configured")
|
||||
|
||||
for tool_input in content.tool_calls:
|
||||
LOGGER.debug(
|
||||
"Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args
|
||||
)
|
||||
|
||||
try:
|
||||
tool_result = await self.llm_api.async_call_tool(tool_input)
|
||||
except (HomeAssistantError, vol.Invalid) as e:
|
||||
tool_result = {"error": type(e).__name__}
|
||||
if str(e):
|
||||
tool_result["error_text"] = str(e)
|
||||
LOGGER.debug("Tool response: %s", tool_result)
|
||||
|
||||
response_content = ToolResultContent(
|
||||
agent_id=content.agent_id,
|
||||
tool_call_id=tool_input.id,
|
||||
tool_name=tool_input.tool_name,
|
||||
tool_result=tool_result,
|
||||
)
|
||||
self.content.append(response_content)
|
||||
yield response_content
|
||||
|
||||
async def async_update_llm_data(
|
||||
self,
|
||||
conversing_domain: str,
|
||||
user_input: ConversationInput,
|
||||
user_llm_hass_api: str | None = None,
|
||||
user_llm_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Set the LLM system prompt."""
|
||||
llm_context = llm.LLMContext(
|
||||
platform=conversing_domain,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
llm_api: llm.APIInstance | None = None
|
||||
|
||||
if user_llm_hass_api:
|
||||
try:
|
||||
llm_api = await llm.async_get_api(
|
||||
self.hass,
|
||||
user_llm_hass_api,
|
||||
llm_context,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
LOGGER.error(
|
||||
"Error getting LLM API %s for %s: %s",
|
||||
user_llm_hass_api,
|
||||
conversing_domain,
|
||||
err,
|
||||
)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Error preparing LLM API",
|
||||
)
|
||||
raise ConverseError(
|
||||
f"Error getting LLM API {user_llm_hass_api}",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
|
||||
user_name: str | None = None
|
||||
|
||||
if (
|
||||
user_input.context
|
||||
and user_input.context.user_id
|
||||
and (
|
||||
user := await self.hass.auth.async_get_user(user_input.context.user_id)
|
||||
)
|
||||
):
|
||||
user_name = user.name
|
||||
|
||||
try:
|
||||
prompt_parts = [
|
||||
template.Template(
|
||||
llm.BASE_PROMPT
|
||||
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
self.hass,
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"user_name": user_name,
|
||||
"llm_context": llm_context,
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
]
|
||||
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem with my template",
|
||||
)
|
||||
raise ConverseError(
|
||||
"Error rendering prompt",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
|
||||
if llm_api:
|
||||
prompt_parts.append(llm_api.api_prompt)
|
||||
|
||||
extra_system_prompt = (
|
||||
# Take new system prompt if one was given
|
||||
user_input.extra_system_prompt or self.extra_system_prompt
|
||||
)
|
||||
|
||||
if extra_system_prompt:
|
||||
prompt_parts.append(extra_system_prompt)
|
||||
|
||||
prompt = "\n".join(prompt_parts)
|
||||
|
||||
self.llm_api = llm_api
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.content[0] = SystemContent(content=prompt)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.content)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
||||
trace.async_conversation_trace_append(
|
||||
trace.ConversationTraceEventType.AGENT_DETAIL,
|
||||
{
|
||||
"messages": self.content,
|
||||
"tools": self.llm_api.tools if self.llm_api else None,
|
||||
},
|
||||
)
|
||||
@@ -42,6 +42,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
|
||||
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||
from homeassistant.helpers import (
|
||||
area_registry as ar,
|
||||
chat_session,
|
||||
device_registry as dr,
|
||||
entity_registry as er,
|
||||
floor_registry as fr,
|
||||
@@ -54,6 +55,7 @@ from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.event import async_track_state_added_domain
|
||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||
|
||||
from .chat_log import AssistantContent, async_get_chat_log
|
||||
from .const import (
|
||||
DATA_DEFAULT_ENTITY,
|
||||
DEFAULT_EXPOSED_ATTRIBUTES,
|
||||
@@ -62,7 +64,6 @@ from .const import (
|
||||
)
|
||||
from .entity import ConversationEntity
|
||||
from .models import ConversationInput, ConversationResult
|
||||
from .session import Content, async_get_chat_session
|
||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
|
||||
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
||||
"""Process a sentence."""
|
||||
response: intent.IntentResponse | None = None
|
||||
async with async_get_chat_session(self.hass, user_input) as chat_session:
|
||||
with (
|
||||
chat_session.async_get_chat_session(
|
||||
self.hass, user_input.conversation_id
|
||||
) as session,
|
||||
async_get_chat_log(self.hass, session, user_input) as chat_log,
|
||||
):
|
||||
# Check if a trigger matched
|
||||
if trigger_result := await self.async_recognize_sentence_trigger(
|
||||
user_input
|
||||
@@ -373,16 +379,15 @@ class DefaultAgent(ConversationEntity):
|
||||
)
|
||||
|
||||
speech: str = response.speech.get("plain", {}).get("speech", "")
|
||||
chat_session.async_add_message(
|
||||
Content(
|
||||
role="assistant",
|
||||
agent_id=user_input.agent_id,
|
||||
chat_log.async_add_assistant_content_without_tools(
|
||||
AssistantContent(
|
||||
agent_id=user_input.agent_id, # type: ignore[arg-type]
|
||||
content=speech,
|
||||
)
|
||||
)
|
||||
|
||||
return ConversationResult(
|
||||
response=response, conversation_id=chat_session.conversation_id
|
||||
response=response, conversation_id=session.conversation_id
|
||||
)
|
||||
|
||||
async def _async_process_intent_result(
|
||||
@@ -805,6 +810,37 @@ class DefaultAgent(ConversationEntity):
|
||||
elif intent_response.unmatched_states:
|
||||
state1 = intent_response.unmatched_states[0]
|
||||
|
||||
state1_translated = ""
|
||||
if state1 is not None:
|
||||
device_class = state1.attributes.get("device_class", "_")
|
||||
state_translations = await translation.async_get_translations(
|
||||
self.hass, language, "state", {state1.domain}
|
||||
)
|
||||
entity_component_translations = await translation.async_get_translations(
|
||||
self.hass, language, "entity_component", {state1.domain}
|
||||
)
|
||||
|
||||
state_key = f"component.{state1.domain}.state.{device_class}.{state1.state}"
|
||||
entity_component_key = f"component.{state1.domain}.entity_component.{device_class}.state.{state1.state}"
|
||||
|
||||
state_translation = state_translations.get(state_key)
|
||||
entity_component_translation = entity_component_translations.get(
|
||||
entity_component_key, state_translation
|
||||
)
|
||||
|
||||
if (not state_translation) and entity_component_translation:
|
||||
# Missing "state._" translation
|
||||
state1_translated = entity_component_translation
|
||||
elif state_translation and (not entity_component_translation):
|
||||
# Missing "entity_component._.state" translation
|
||||
state1_translated = state_translation
|
||||
if state_translation and (
|
||||
state_translation != entity_component_translation
|
||||
):
|
||||
# Prefer "state._" translation since English is the fallback
|
||||
# language and doesn't necessarily have it.
|
||||
state1_translated = state_translation
|
||||
|
||||
# Render response template
|
||||
speech_slots = {
|
||||
entity_name: entity_value.text or entity_value.value
|
||||
@@ -822,6 +858,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if state1 is not None
|
||||
else None
|
||||
),
|
||||
"state_translated": state1_translated,
|
||||
"query": {
|
||||
# Entity states that matched the query (e.g, "on")
|
||||
"matched": [
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
"""Conversation history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import (
|
||||
CALLBACK_TYPE,
|
||||
Event,
|
||||
HassJob,
|
||||
HassJobType,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, TemplateError
|
||||
from homeassistant.helpers import intent, llm, template
|
||||
from homeassistant.helpers.event import async_call_later
|
||||
from homeassistant.util import dt as dt_util, ulid as ulid_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import trace
|
||||
from .const import DOMAIN
|
||||
from .models import ConversationInput, ConversationResult
|
||||
|
||||
DATA_CHAT_HISTORY: HassKey[dict[str, ChatSession]] = HassKey(
|
||||
"conversation_chat_session"
|
||||
)
|
||||
DATA_CHAT_HISTORY_CLEANUP: HassKey[SessionCleanup] = HassKey(
|
||||
"conversation_chat_session_cleanup"
|
||||
)
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
CONVERSATION_TIMEOUT = timedelta(minutes=5)
|
||||
|
||||
|
||||
class SessionCleanup:
|
||||
"""Helper to clean up the history."""
|
||||
|
||||
unsub: CALLBACK_TYPE | None = None
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the history cleanup."""
|
||||
self.hass = hass
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._on_hass_stop)
|
||||
self.cleanup_job = HassJob(
|
||||
self._cleanup, "conversation_history_cleanup", job_type=HassJobType.Callback
|
||||
)
|
||||
|
||||
@callback
|
||||
def schedule(self) -> None:
|
||||
"""Schedule the cleanup."""
|
||||
if self.unsub:
|
||||
return
|
||||
self.unsub = async_call_later(
|
||||
self.hass,
|
||||
CONVERSATION_TIMEOUT.total_seconds() + 1,
|
||||
self.cleanup_job,
|
||||
)
|
||||
|
||||
@callback
|
||||
def _on_hass_stop(self, event: Event) -> None:
|
||||
"""Cancel the cleanup on shutdown."""
|
||||
if self.unsub:
|
||||
self.unsub()
|
||||
self.unsub = None
|
||||
|
||||
@callback
|
||||
def _cleanup(self, now: datetime) -> None:
|
||||
"""Clean up the history and schedule follow-up if necessary."""
|
||||
self.unsub = None
|
||||
all_history = self.hass.data[DATA_CHAT_HISTORY]
|
||||
|
||||
# We mutate original object because current commands could be
|
||||
# yielding history based on it.
|
||||
for conversation_id, history in list(all_history.items()):
|
||||
if history.last_updated + CONVERSATION_TIMEOUT < now:
|
||||
del all_history[conversation_id]
|
||||
|
||||
# Still conversations left, check again in timeout time.
|
||||
if all_history:
|
||||
self.schedule()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def async_get_chat_session(
|
||||
hass: HomeAssistant,
|
||||
user_input: ConversationInput,
|
||||
) -> AsyncGenerator[ChatSession]:
|
||||
"""Return chat session."""
|
||||
all_history = hass.data.get(DATA_CHAT_HISTORY)
|
||||
if all_history is None:
|
||||
all_history = {}
|
||||
hass.data[DATA_CHAT_HISTORY] = all_history
|
||||
hass.data[DATA_CHAT_HISTORY_CLEANUP] = SessionCleanup(hass)
|
||||
|
||||
history: ChatSession | None = None
|
||||
|
||||
if user_input.conversation_id is None:
|
||||
conversation_id = ulid_util.ulid_now()
|
||||
|
||||
elif history := all_history.get(user_input.conversation_id):
|
||||
conversation_id = user_input.conversation_id
|
||||
|
||||
else:
|
||||
# Conversation IDs are ULIDs. We generate a new one if not provided.
|
||||
# If an old OLID is passed in, we will generate a new one to indicate
|
||||
# a new conversation was started. If the user picks their own, they
|
||||
# want to track a conversation and we respect it.
|
||||
try:
|
||||
ulid_util.ulid_to_bytes(user_input.conversation_id)
|
||||
conversation_id = ulid_util.ulid_now()
|
||||
except ValueError:
|
||||
conversation_id = user_input.conversation_id
|
||||
|
||||
if history:
|
||||
history = replace(history, messages=history.messages.copy())
|
||||
else:
|
||||
history = ChatSession(hass, conversation_id, user_input.agent_id)
|
||||
|
||||
message: Content = Content(
|
||||
role="user",
|
||||
agent_id=user_input.agent_id,
|
||||
content=user_input.text,
|
||||
)
|
||||
history.async_add_message(message)
|
||||
|
||||
yield history
|
||||
|
||||
if history.messages[-1] is message:
|
||||
LOGGER.debug(
|
||||
"History opened but no assistant message was added, ignoring update"
|
||||
)
|
||||
return
|
||||
|
||||
history.last_updated = dt_util.utcnow()
|
||||
all_history[conversation_id] = history
|
||||
hass.data[DATA_CHAT_HISTORY_CLEANUP].schedule()
|
||||
|
||||
|
||||
class ConverseError(HomeAssistantError):
|
||||
"""Error during initialization of conversation.
|
||||
|
||||
Will not be stored in the history.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, message: str, conversation_id: str, response: intent.IntentResponse
|
||||
) -> None:
|
||||
"""Initialize the error."""
|
||||
super().__init__(message)
|
||||
self.conversation_id = conversation_id
|
||||
self.response = response
|
||||
|
||||
def as_conversation_result(self) -> ConversationResult:
|
||||
"""Return the error as a conversation result."""
|
||||
return ConversationResult(
|
||||
response=self.response,
|
||||
conversation_id=self.conversation_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Content:
|
||||
"""Base class for chat messages."""
|
||||
|
||||
role: Literal["system", "assistant", "user"]
|
||||
agent_id: str | None
|
||||
content: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeContent[_NativeT]:
|
||||
"""Native content."""
|
||||
|
||||
role: str = field(init=False, default="native")
|
||||
agent_id: str
|
||||
content: _NativeT
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChatSession[_NativeT]:
|
||||
"""Class holding all information for a specific conversation."""
|
||||
|
||||
hass: HomeAssistant
|
||||
conversation_id: str
|
||||
agent_id: str | None
|
||||
user_name: str | None = None
|
||||
messages: list[Content | NativeContent[_NativeT]] = field(
|
||||
default_factory=lambda: [Content(role="system", agent_id=None, content="")]
|
||||
)
|
||||
extra_system_prompt: str | None = None
|
||||
llm_api: llm.APIInstance | None = None
|
||||
last_updated: datetime = field(default_factory=dt_util.utcnow)
|
||||
|
||||
@callback
|
||||
def async_add_message(self, message: Content | NativeContent[_NativeT]) -> None:
|
||||
"""Process intent."""
|
||||
if message.role == "system":
|
||||
raise ValueError("Cannot add system messages to history")
|
||||
if message.role != "native" and self.messages[-1].role == message.role:
|
||||
raise ValueError("Cannot add two assistant or user messages in a row")
|
||||
|
||||
self.messages.append(message)
|
||||
|
||||
@callback
|
||||
def async_get_messages(
|
||||
self, agent_id: str | None = None
|
||||
) -> list[Content | NativeContent[_NativeT]]:
|
||||
"""Get messages for a specific agent ID.
|
||||
|
||||
This will filter out any native message tied to other agent IDs.
|
||||
It can still include assistant/user messages generated by other agents.
|
||||
"""
|
||||
return [
|
||||
message
|
||||
for message in self.messages
|
||||
if message.role != "native" or message.agent_id == agent_id
|
||||
]
|
||||
|
||||
async def async_update_llm_data(
|
||||
self,
|
||||
conversing_domain: str,
|
||||
user_input: ConversationInput,
|
||||
user_llm_hass_api: str | None = None,
|
||||
user_llm_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Set the LLM system prompt."""
|
||||
llm_context = llm.LLMContext(
|
||||
platform=conversing_domain,
|
||||
context=user_input.context,
|
||||
user_prompt=user_input.text,
|
||||
language=user_input.language,
|
||||
assistant=DOMAIN,
|
||||
device_id=user_input.device_id,
|
||||
)
|
||||
|
||||
llm_api: llm.APIInstance | None = None
|
||||
|
||||
if user_llm_hass_api:
|
||||
try:
|
||||
llm_api = await llm.async_get_api(
|
||||
self.hass,
|
||||
user_llm_hass_api,
|
||||
llm_context,
|
||||
)
|
||||
except HomeAssistantError as err:
|
||||
LOGGER.error(
|
||||
"Error getting LLM API %s for %s: %s",
|
||||
user_llm_hass_api,
|
||||
conversing_domain,
|
||||
err,
|
||||
)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Error preparing LLM API",
|
||||
)
|
||||
raise ConverseError(
|
||||
f"Error getting LLM API {user_llm_hass_api}",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
|
||||
user_name: str | None = None
|
||||
|
||||
if (
|
||||
user_input.context
|
||||
and user_input.context.user_id
|
||||
and (
|
||||
user := await self.hass.auth.async_get_user(user_input.context.user_id)
|
||||
)
|
||||
):
|
||||
user_name = user.name
|
||||
|
||||
try:
|
||||
prompt_parts = [
|
||||
template.Template(
|
||||
llm.BASE_PROMPT
|
||||
+ (user_llm_prompt or llm.DEFAULT_INSTRUCTIONS_PROMPT),
|
||||
self.hass,
|
||||
).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"user_name": user_name,
|
||||
"llm_context": llm_context,
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
]
|
||||
|
||||
except TemplateError as err:
|
||||
LOGGER.error("Error rendering prompt: %s", err)
|
||||
intent_response = intent.IntentResponse(language=user_input.language)
|
||||
intent_response.async_set_error(
|
||||
intent.IntentResponseErrorCode.UNKNOWN,
|
||||
"Sorry, I had a problem with my template",
|
||||
)
|
||||
raise ConverseError(
|
||||
"Error rendering prompt",
|
||||
conversation_id=self.conversation_id,
|
||||
response=intent_response,
|
||||
) from err
|
||||
|
||||
if llm_api:
|
||||
prompt_parts.append(llm_api.api_prompt)
|
||||
|
||||
extra_system_prompt = (
|
||||
# Take new system prompt if one was given
|
||||
user_input.extra_system_prompt or self.extra_system_prompt
|
||||
)
|
||||
|
||||
if extra_system_prompt:
|
||||
prompt_parts.append(extra_system_prompt)
|
||||
|
||||
prompt = "\n".join(prompt_parts)
|
||||
|
||||
self.llm_api = llm_api
|
||||
self.user_name = user_name
|
||||
self.extra_system_prompt = extra_system_prompt
|
||||
self.messages[0] = Content(
|
||||
role="system",
|
||||
agent_id=user_input.agent_id,
|
||||
content=prompt,
|
||||
)
|
||||
|
||||
LOGGER.debug("Prompt: %s", self.messages)
|
||||
LOGGER.debug("Tools: %s", self.llm_api.tools if self.llm_api else None)
|
||||
|
||||
trace.async_conversation_trace_append(
|
||||
trace.ConversationTraceEventType.AGENT_DETAIL,
|
||||
{
|
||||
"messages": self.messages,
|
||||
"tools": self.llm_api.tools if self.llm_api else None,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_call_tool(self, tool_input: llm.ToolInput) -> JsonObjectType:
|
||||
"""Invoke LLM tool for the configured LLM API."""
|
||||
if not self.llm_api:
|
||||
raise ValueError("No LLM API configured")
|
||||
LOGGER.debug("Tool call: %s(%s)", tool_input.tool_name, tool_input.tool_args)
|
||||
|
||||
try:
|
||||
tool_response = await self.llm_api.async_call_tool(tool_input)
|
||||
except (HomeAssistantError, vol.Invalid) as e:
|
||||
tool_response = {"error": type(e).__name__}
|
||||
if str(e):
|
||||
tool_response["error_text"] = str(e)
|
||||
LOGGER.debug("Tool response: %s", tool_response)
|
||||
return tool_response
|
||||
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.0.2",
|
||||
"aiodhcpwatcher==1.0.3",
|
||||
"aiodiscover==2.1.0",
|
||||
"cached-ipaddress==0.8.0"
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Data is fetched from DWD:
|
||||
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
|
||||
|
||||
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor
|
||||
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor,extremem
|
||||
Unwetterwarnungen (Stufe 3)
|
||||
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
||||
Wetterwarnungen (Stufe 1)
|
||||
|
||||
@@ -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.10", "deebot-client==11.1.0b1"]
|
||||
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0b0"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.heater import EheimDigitalHeater
|
||||
from eheimdigital.types import EheimDigitalClientError, HeaterMode, HeaterUnit
|
||||
|
||||
@@ -39,17 +40,23 @@ async def async_setup_entry(
|
||||
"""Set up the callbacks for the coordinator so climate entities can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async def async_setup_device_entities(device_address: str) -> None:
|
||||
"""Set up the light entities for a device."""
|
||||
device = coordinator.hub.devices[device_address]
|
||||
def async_setup_device_entities(
|
||||
device_address: str | dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the climate entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalHeaterClimate] = []
|
||||
if isinstance(device_address, str):
|
||||
device_address = {device_address: coordinator.hub.devices[device_address]}
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalHeater):
|
||||
entities.append(EheimDigitalHeaterClimate(coordinator, device))
|
||||
coordinator.known_devices.add(device.mac_address)
|
||||
|
||||
if isinstance(device, EheimDigitalHeater):
|
||||
async_add_entities([EheimDigitalHeaterClimate(coordinator, device)])
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
|
||||
for device_address in entry.runtime_data.hub.devices:
|
||||
await async_setup_device_entities(device_address)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateEntity):
|
||||
@@ -69,6 +76,7 @@ class EheimDigitalHeaterClimate(EheimDigitalEntity[EheimDigitalHeater], ClimateE
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_preset_mode = PRESET_NONE
|
||||
_attr_translation_key = "heater"
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self, coordinator: EheimDigitalUpdateCoordinator, device: EheimDigitalHeater
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any
|
||||
from collections.abc import Callable
|
||||
|
||||
from aiohttp import ClientError
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
@@ -19,7 +18,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
type AsyncSetupDeviceEntitiesCallback = Callable[[str], Coroutine[Any, Any, None]]
|
||||
type AsyncSetupDeviceEntitiesCallback = Callable[
|
||||
[str | dict[str, EheimDigitalDevice]], None
|
||||
]
|
||||
|
||||
|
||||
class EheimDigitalUpdateCoordinator(
|
||||
@@ -61,7 +62,7 @@ class EheimDigitalUpdateCoordinator(
|
||||
|
||||
if device_address not in self.known_devices:
|
||||
for platform_callback in self.platform_callbacks:
|
||||
await platform_callback(device_address)
|
||||
platform_callback(device_address)
|
||||
|
||||
async def _async_receive_callback(self) -> None:
|
||||
self.async_set_updated_data(self.hub.devices)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from typing import Any
|
||||
|
||||
from eheimdigital.classic_led_ctrl import EheimDigitalClassicLEDControl
|
||||
from eheimdigital.device import EheimDigitalDevice
|
||||
from eheimdigital.types import EheimDigitalClientError, LightMode
|
||||
|
||||
from homeassistant.components.light import (
|
||||
@@ -37,24 +38,28 @@ async def async_setup_entry(
|
||||
"""Set up the callbacks for the coordinator so lights can be added as devices are found."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async def async_setup_device_entities(device_address: str) -> None:
|
||||
"""Set up the light entities for a device."""
|
||||
device = coordinator.hub.devices[device_address]
|
||||
def async_setup_device_entities(
|
||||
device_address: str | dict[str, EheimDigitalDevice],
|
||||
) -> None:
|
||||
"""Set up the light entities for one or multiple devices."""
|
||||
entities: list[EheimDigitalClassicLEDControlLight] = []
|
||||
if isinstance(device_address, str):
|
||||
device_address = {device_address: coordinator.hub.devices[device_address]}
|
||||
for device in device_address.values():
|
||||
if isinstance(device, EheimDigitalClassicLEDControl):
|
||||
for channel in range(2):
|
||||
if len(device.tankconfig[channel]) > 0:
|
||||
entities.append(
|
||||
EheimDigitalClassicLEDControlLight(
|
||||
coordinator, device, channel
|
||||
)
|
||||
)
|
||||
coordinator.known_devices.add(device.mac_address)
|
||||
|
||||
if isinstance(device, EheimDigitalClassicLEDControl):
|
||||
for channel in range(2):
|
||||
if len(device.tankconfig[channel]) > 0:
|
||||
entities.append(
|
||||
EheimDigitalClassicLEDControlLight(coordinator, device, channel)
|
||||
)
|
||||
coordinator.known_devices.add(device.mac_address)
|
||||
async_add_entities(entities)
|
||||
|
||||
coordinator.add_platform_callback(async_setup_device_entities)
|
||||
|
||||
for device_address in entry.runtime_data.hub.devices:
|
||||
await async_setup_device_entities(device_address)
|
||||
async_setup_device_entities(coordinator.hub.devices)
|
||||
|
||||
|
||||
class EheimDigitalClassicLEDControlLight(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Searching for Energenie-Power-Sockets Devices.",
|
||||
"title": "Searching for Energenie Power Sockets devices",
|
||||
"description": "Choose a discovered device.",
|
||||
"data": {
|
||||
"device": "[%key:common::config_flow::data::device%]"
|
||||
@@ -13,7 +13,7 @@
|
||||
"abort": {
|
||||
"usb_error": "Couldn't access USB devices!",
|
||||
"no_device": "Unable to discover any (new) supported device.",
|
||||
"device_not_found": "No device was found for the given id.",
|
||||
"device_not_found": "No device was found for the given ID.",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyenphase import EnvoyData
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from httpx import HTTPError
|
||||
from pyenphase import EnvoyData
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
|
||||
ACTIONERRORS = (EnvoyError, HTTPError)
|
||||
|
||||
|
||||
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
||||
"""Defines a base envoy entity."""
|
||||
@@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
||||
data = self.coordinator.envoy.data
|
||||
assert data is not None
|
||||
return data
|
||||
|
||||
|
||||
def exception_handler[_EntityT: EnvoyBaseEntity, **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate Enphase Envoy calls to handle exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches enphase_envoy errors.
|
||||
"""
|
||||
|
||||
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except ACTIONERRORS as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_error",
|
||||
translation_placeholders={
|
||||
"host": self.coordinator.envoy.host,
|
||||
"args": error.args[0],
|
||||
"action": func.__name__,
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
) from error
|
||||
|
||||
return handler
|
||||
|
||||
@@ -400,6 +400,9 @@
|
||||
},
|
||||
"envoy_error": {
|
||||
"message": "Error communicating with Envoy API on {host}: {args}"
|
||||
},
|
||||
"action_error": {
|
||||
"message": "Failed to execute {action} for {entity}, host: {host}: {args}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
from .entity import EnvoyBaseEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||
assert enpower is not None
|
||||
return self.entity_description.value_fn(enpower)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Enpower switch."""
|
||||
await self.entity_description.turn_on_fn(self.envoy)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Enpower switch."""
|
||||
await self.entity_description.turn_off_fn(self.envoy)
|
||||
@@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||
assert relay is not None
|
||||
return self.entity_description.value_fn(relay)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on (close) the dry contact."""
|
||||
if await self.entity_description.turn_on_fn(self.envoy, self.relay_id):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off (open) the dry contact."""
|
||||
if await self.entity_description.turn_off_fn(self.envoy, self.relay_id):
|
||||
@@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||
assert self.data.tariff.storage_settings is not None
|
||||
return self.entity_description.value_fn(self.data.tariff.storage_settings)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the storage settings switch."""
|
||||
await self.entity_description.turn_on_fn(self.envoy)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the storage switch."""
|
||||
await self.entity_description.turn_off_fn(self.envoy)
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eq3btsmart"],
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.2.0"]
|
||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.0"]
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ def async_connect_scanner(
|
||||
entry_data: RuntimeEntryData,
|
||||
cli: APIClient,
|
||||
device_info: DeviceInfo,
|
||||
device_id: str,
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Connect scanner."""
|
||||
client_data = connect_scanner(cli, device_info, entry_data.available)
|
||||
@@ -45,6 +46,7 @@ def async_connect_scanner(
|
||||
source_domain=DOMAIN,
|
||||
source_model=device_info.model,
|
||||
source_config_entry_id=entry_data.entry_id,
|
||||
source_device_id=device_id,
|
||||
),
|
||||
scanner.async_setup(),
|
||||
],
|
||||
|
||||
@@ -425,7 +425,9 @@ class ESPHomeManager:
|
||||
|
||||
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
||||
entry_data.disconnect_callbacks.add(
|
||||
async_connect_scanner(hass, entry_data, cli, device_info)
|
||||
async_connect_scanner(
|
||||
hass, entry_data, cli, device_info, self.device_id
|
||||
)
|
||||
)
|
||||
else:
|
||||
bluetooth.async_remove_scanner(hass, device_info.mac_address)
|
||||
@@ -571,7 +573,9 @@ def _async_setup_device_registry(
|
||||
|
||||
configuration_url = None
|
||||
if device_info.webserver_port > 0:
|
||||
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
|
||||
entry_host = entry.data["host"]
|
||||
host = f"[{entry_host}]" if ":" in entry_host else entry_host
|
||||
configuration_url = f"http://{host}:{device_info.webserver_port}"
|
||||
elif (
|
||||
(dashboard := async_get_dashboard(hass))
|
||||
and dashboard.data
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"requirements": [
|
||||
"aioesphomeapi==29.0.0",
|
||||
"esphome-dashboard-api==1.2.3",
|
||||
"bleak-esphome==2.2.0"
|
||||
"bleak-esphome==2.7.0"
|
||||
],
|
||||
"zeroconf": ["_esphomelib._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -1,33 +1,27 @@
|
||||
"""The FAA Delays integration."""
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FAADataUpdateCoordinator
|
||||
from .coordinator import FAAConfigEntry, FAADataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
|
||||
"""Set up FAA Delays from a config entry."""
|
||||
code = entry.data[CONF_ID]
|
||||
|
||||
coordinator = FAADataUpdateCoordinator(hass, code)
|
||||
coordinator = FAADataUpdateCoordinator(hass, entry, code)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -12,13 +12,12 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from . import FAADataUpdateCoordinator
|
||||
from . import FAAConfigEntry, FAADataUpdateCoordinator
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
@@ -84,10 +83,10 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = (
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback
|
||||
) -> None:
|
||||
"""Set up a FAA sensor based on a config entry."""
|
||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
entities = [
|
||||
FAABinarySensor(coordinator, entry.entry_id, description)
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
from aiohttp import ClientConnectionError
|
||||
from faadelays import Airport
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import aiohttp_client
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
@@ -15,14 +16,20 @@ from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type FAAConfigEntry = ConfigEntry[FAADataUpdateCoordinator]
|
||||
|
||||
|
||||
class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]):
|
||||
"""Class to manage fetching FAA API data from a single endpoint."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, code: str) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: FAAConfigEntry, code: str) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
self.session = aiohttp_client.async_get_clientsession(hass)
|
||||
self.data = Airport(code, self.session)
|
||||
|
||||
@@ -4,20 +4,20 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.start import async_at_started
|
||||
|
||||
from .const import DOMAIN, PLATFORMS
|
||||
from .coordinator import FastdotcomDataUpdateCoordinator
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool:
|
||||
"""Set up Fast.com from a config entry."""
|
||||
coordinator = FastdotcomDataUpdateCoordinator(hass)
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
||||
coordinator = FastdotcomDataUpdateCoordinator(hass, entry)
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry,
|
||||
@@ -36,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool:
|
||||
"""Unload Fast.com config entry."""
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -6,20 +6,24 @@ from datetime import timedelta
|
||||
|
||||
from fastdotcom import fast_com
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER
|
||||
|
||||
type FastdotcomConfigEntry = ConfigEntry[FastdotcomDataUpdateCoordinator]
|
||||
|
||||
|
||||
class FastdotcomDataUpdateCoordinator(DataUpdateCoordinator[float]):
|
||||
"""Class to manage fetching Fast.com data API."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
def __init__(self, hass: HomeAssistant, entry: FastdotcomConfigEntry) -> None:
|
||||
"""Initialize the coordinator for Fast.com."""
|
||||
super().__init__(
|
||||
hass,
|
||||
LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(hours=DEFAULT_INTERVAL),
|
||||
)
|
||||
|
||||
@@ -4,21 +4,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FastdotcomDataUpdateCoordinator
|
||||
from .coordinator import FastdotcomConfigEntry
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
hass: HomeAssistant, config_entry: FastdotcomConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for the config entry."""
|
||||
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][
|
||||
config_entry.entry_id
|
||||
]
|
||||
|
||||
return {
|
||||
"coordinator_data": coordinator.data,
|
||||
}
|
||||
return {"coordinator_data": config_entry.runtime_data.data}
|
||||
|
||||
@@ -7,7 +7,6 @@ from homeassistant.components.sensor import (
|
||||
SensorEntity,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfDataRate
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
@@ -15,17 +14,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FastdotcomDataUpdateCoordinator
|
||||
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: FastdotcomConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Fast.com sensor."""
|
||||
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
||||
async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)])
|
||||
async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)])
|
||||
|
||||
|
||||
class SpeedtestSensor(CoordinatorEntity[FastdotcomDataUpdateCoordinator], SensorEntity):
|
||||
|
||||
@@ -21,9 +21,11 @@ from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import raise_if_invalid_filename
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
from homeassistant.util.ulid import ulid_hex
|
||||
|
||||
DOMAIN = "file_upload"
|
||||
_DATA: HassKey[FileUploadData] = HassKey(DOMAIN)
|
||||
|
||||
ONE_MEGABYTE = 1024 * 1024
|
||||
MAX_SIZE = 100 * ONE_MEGABYTE
|
||||
@@ -41,7 +43,7 @@ def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
|
||||
if DOMAIN not in hass.data:
|
||||
raise ValueError("File does not exist")
|
||||
|
||||
file_upload_data: FileUploadData = hass.data[DOMAIN]
|
||||
file_upload_data = hass.data[_DATA]
|
||||
|
||||
if not file_upload_data.has_file(file_id):
|
||||
raise ValueError("File does not exist")
|
||||
@@ -149,10 +151,10 @@ class FileUploadView(HomeAssistantView):
|
||||
hass = request.app[KEY_HASS]
|
||||
file_id = ulid_hex()
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
hass.data[DOMAIN] = await FileUploadData.create(hass)
|
||||
if _DATA not in hass.data:
|
||||
hass.data[_DATA] = await FileUploadData.create(hass)
|
||||
|
||||
file_upload_data: FileUploadData = hass.data[DOMAIN]
|
||||
file_upload_data = hass.data[_DATA]
|
||||
file_dir = file_upload_data.file_dir(file_id)
|
||||
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
|
||||
SimpleQueue()
|
||||
@@ -206,7 +208,7 @@ class FileUploadView(HomeAssistantView):
|
||||
raise web.HTTPNotFound
|
||||
|
||||
file_id = data["file_id"]
|
||||
file_upload_data: FileUploadData = hass.data[DOMAIN]
|
||||
file_upload_data = hass.data[_DATA]
|
||||
|
||||
if file_upload_data.files.pop(file_id, None) is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250129.0"]
|
||||
"requirements": ["home-assistant-frontend==20250203.0"]
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
||||
"""Send volume up command."""
|
||||
volume = await self.fs_device.get_volume()
|
||||
volume = int(volume or 0) + 1
|
||||
await self.fs_device.set_volume(min(volume, self._max_volume))
|
||||
await self.fs_device.set_volume(min(volume, self._max_volume or 1))
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
@@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity):
|
||||
value, extra_state_attributes = self.entity_description.state_fn(value)
|
||||
|
||||
if self.entity_description.round_state_value:
|
||||
if TYPE_CHECKING:
|
||||
assert isinstance(value, int)
|
||||
value = round_storage(value)
|
||||
|
||||
self._attr_native_value = value
|
||||
|
||||
@@ -28,14 +28,14 @@
|
||||
"user": {
|
||||
"description": "Enter the settings to connect to the camera.",
|
||||
"data": {
|
||||
"still_image_url": "Still Image URL (e.g. http://...)",
|
||||
"stream_source": "Stream Source URL (e.g. rtsp://...)",
|
||||
"still_image_url": "Still image URL (e.g. http://...)",
|
||||
"stream_source": "Stream source URL (e.g. rtsp://...)",
|
||||
"rtsp_transport": "RTSP transport protocol",
|
||||
"authentication": "Authentication",
|
||||
"limit_refetch_to_url_change": "Limit refetch to url change",
|
||||
"limit_refetch_to_url_change": "Limit refetch to URL change",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"framerate": "Frame Rate (Hz)",
|
||||
"framerate": "Frame rate (Hz)",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ from aiohttp import ClientSession, ClientTimeout, StreamReader
|
||||
from aiohttp.client_exceptions import ClientError, ClientResponseError
|
||||
from google_drive_api.api import AbstractAuth, GoogleDriveApi
|
||||
|
||||
from homeassistant.components.backup import AgentBackup
|
||||
from homeassistant.components.backup import AgentBackup, suggested_filename
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.exceptions import (
|
||||
@@ -132,7 +132,7 @@ class DriveClient:
|
||||
"""Upload a backup."""
|
||||
folder_id, _ = await self.async_create_ha_root_folder_if_not_exists()
|
||||
backup_metadata = {
|
||||
"name": f"{backup.name} {backup.date}.tar",
|
||||
"name": suggested_filename(backup),
|
||||
"description": json.dumps(backup.as_dict()),
|
||||
"parents": [folder_id],
|
||||
"properties": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
@@ -18,4 +19,5 @@ async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, s
|
||||
"oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent",
|
||||
"more_info_url": "https://www.home-assistant.io/integrations/google_drive/",
|
||||
"oauth_creds_url": "https://console.cloud.google.com/apis/credentials",
|
||||
"redirect_url": config_entry_oauth2_flow.async_get_redirect_uri(hass),
|
||||
}
|
||||
|
||||
@@ -80,16 +80,14 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
try:
|
||||
await self._client.async_upload_backup(open_stream, backup)
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
_LOGGER.error("Upload backup error: %s", err)
|
||||
raise BackupAgentError("Failed to upload backup") from err
|
||||
raise BackupAgentError(f"Failed to upload backup: {err}") from err
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
try:
|
||||
return await self._client.async_list_backups()
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
_LOGGER.error("List backups error: %s", err)
|
||||
raise BackupAgentError("Failed to list backups") from err
|
||||
raise BackupAgentError(f"Failed to list backups: {err}") from err
|
||||
|
||||
async def async_get_backup(
|
||||
self,
|
||||
@@ -121,9 +119,7 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
stream = await self._client.async_download(file_id)
|
||||
return ChunkAsyncStreamIterator(stream)
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
_LOGGER.error("Download backup error: %s", err)
|
||||
raise BackupAgentError("Failed to download backup") from err
|
||||
_LOGGER.error("Download backup_id: %s not found", backup_id)
|
||||
raise BackupAgentError(f"Failed to download backup: {err}") from err
|
||||
raise BackupAgentError("Backup not found")
|
||||
|
||||
async def async_delete_backup(
|
||||
@@ -143,5 +139,4 @@ class GoogleDriveBackupAgent(BackupAgent):
|
||||
await self._client.async_delete(file_id)
|
||||
_LOGGER.debug("Deleted backup_id: %s", backup_id)
|
||||
except (GoogleDriveApiError, HomeAssistantError, TimeoutError) as err:
|
||||
_LOGGER.error("Delete backup error: %s", err)
|
||||
raise BackupAgentError("Failed to delete backup") from err
|
||||
raise BackupAgentError(f"Failed to delete backup: {err}") from err
|
||||
|
||||
@@ -35,6 +35,6 @@
|
||||
}
|
||||
},
|
||||
"application_credentials": {
|
||||
"description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Drive. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type."
|
||||
"description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and select **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from google.api_core.exceptions import GoogleAPIError
|
||||
import google.generativeai as genai
|
||||
@@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
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 chat_session, device_registry as dr, intent, llm
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
@@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _chat_message_convert(
|
||||
message: conversation.Content | conversation.NativeContent[genai_types.ContentDict],
|
||||
) -> genai_types.ContentDict:
|
||||
"""Convert any native chat message for this agent to the native format."""
|
||||
if message.role == "native":
|
||||
return message.content
|
||||
def _create_google_tool_response_content(
|
||||
content: list[conversation.ToolResultContent],
|
||||
) -> protos.Content:
|
||||
"""Create a Google tool response content."""
|
||||
return protos.Content(
|
||||
parts=[
|
||||
protos.Part(
|
||||
function_response=protos.FunctionResponse(
|
||||
name=tool_result.tool_name, response=tool_result.tool_result
|
||||
)
|
||||
)
|
||||
for tool_result in content
|
||||
]
|
||||
)
|
||||
|
||||
role = "model" if message.role == "assistant" else message.role
|
||||
return {"role": role, "parts": message.content}
|
||||
|
||||
def _convert_content(
|
||||
content: conversation.UserContent
|
||||
| conversation.AssistantContent
|
||||
| conversation.SystemContent,
|
||||
) -> genai_types.ContentDict:
|
||||
"""Convert HA content to Google content."""
|
||||
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
|
||||
role = "model" if content.role == "assistant" else content.role
|
||||
return {"role": role, "parts": content.content}
|
||||
|
||||
# Handle the Assistant content with tool calls.
|
||||
assert type(content) is conversation.AssistantContent
|
||||
parts = []
|
||||
|
||||
if content.content:
|
||||
parts.append(protos.Part(text=content.content))
|
||||
|
||||
if content.tool_calls:
|
||||
parts.extend(
|
||||
[
|
||||
protos.Part(
|
||||
function_call=protos.FunctionCall(
|
||||
name=tool_call.tool_name,
|
||||
args=_escape_decode(tool_call.tool_args),
|
||||
)
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
|
||||
return protos.Content({"role": "model", "parts": parts})
|
||||
|
||||
|
||||
class GoogleGenerativeAIConversationEntity(
|
||||
@@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity(
|
||||
self, user_input: conversation.ConversationInput
|
||||
) -> conversation.ConversationResult:
|
||||
"""Process a sentence."""
|
||||
async with conversation.async_get_chat_session(
|
||||
self.hass, user_input
|
||||
) as session:
|
||||
return await self._async_handle_message(user_input, session)
|
||||
with (
|
||||
chat_session.async_get_chat_session(
|
||||
self.hass, user_input.conversation_id
|
||||
) as session,
|
||||
conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
|
||||
):
|
||||
return await self._async_handle_message(user_input, chat_log)
|
||||
|
||||
async def _async_handle_message(
|
||||
self,
|
||||
user_input: conversation.ConversationInput,
|
||||
session: conversation.ChatSession[genai_types.ContentDict],
|
||||
chat_log: conversation.ChatLog,
|
||||
) -> conversation.ConversationResult:
|
||||
"""Call the API."""
|
||||
|
||||
@@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
options = self.entry.options
|
||||
|
||||
try:
|
||||
await session.async_update_llm_data(
|
||||
await chat_log.async_update_llm_data(
|
||||
DOMAIN,
|
||||
user_input,
|
||||
options.get(CONF_LLM_HASS_API),
|
||||
@@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity(
|
||||
return err.as_conversation_result()
|
||||
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
if session.llm_api:
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, session.llm_api.custom_serializer)
|
||||
for tool in session.llm_api.tools
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
||||
@@ -249,9 +290,36 @@ class GoogleGenerativeAIConversationEntity(
|
||||
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
|
||||
)
|
||||
|
||||
prompt, *messages = [
|
||||
_chat_message_convert(message) for message in session.async_get_messages()
|
||||
]
|
||||
prompt = chat_log.content[0].content # type: ignore[union-attr]
|
||||
messages: list[genai_types.ContentDict] = []
|
||||
|
||||
# Google groups tool results, we do not. Group them before sending.
|
||||
tool_results: list[conversation.ToolResultContent] = []
|
||||
|
||||
for chat_content in chat_log.content[1:]:
|
||||
if chat_content.role == "tool_result":
|
||||
# mypy doesn't like picking a type based on checking shared property 'role'
|
||||
tool_results.append(cast(conversation.ToolResultContent, chat_content))
|
||||
continue
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
tool_results.clear()
|
||||
|
||||
messages.append(
|
||||
_convert_content(
|
||||
cast(
|
||||
conversation.UserContent
|
||||
| conversation.SystemContent
|
||||
| conversation.AssistantContent,
|
||||
chat_content,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
|
||||
model = genai.GenerativeModel(
|
||||
model_name=model_name,
|
||||
generation_config={
|
||||
@@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity(
|
||||
),
|
||||
},
|
||||
tools=tools or None,
|
||||
system_instruction=prompt["parts"] if supports_system_instruction else None,
|
||||
system_instruction=prompt if supports_system_instruction else None,
|
||||
)
|
||||
|
||||
if not supports_system_instruction:
|
||||
messages = [
|
||||
{"role": "user", "parts": prompt["parts"]},
|
||||
{"role": "user", "parts": prompt},
|
||||
{"role": "model", "parts": "Ok"},
|
||||
*messages,
|
||||
]
|
||||
@@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity(
|
||||
content = " ".join(
|
||||
[part.text.strip() for part in chat_response.parts if part.text]
|
||||
)
|
||||
if content:
|
||||
session.async_add_message(
|
||||
conversation.Content(
|
||||
role="assistant",
|
||||
agent_id=user_input.agent_id,
|
||||
content=content,
|
||||
)
|
||||
)
|
||||
|
||||
function_calls = [
|
||||
part.function_call for part in chat_response.parts if part.function_call
|
||||
]
|
||||
|
||||
if not function_calls or not session.llm_api:
|
||||
break
|
||||
|
||||
tool_responses = []
|
||||
for function_call in function_calls:
|
||||
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
|
||||
tool_calls = []
|
||||
for part in chat_response.parts:
|
||||
if not part.function_call:
|
||||
continue
|
||||
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = _escape_decode(tool_call["args"])
|
||||
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||
function_response = await session.async_call_tool(tool_input)
|
||||
tool_responses.append(
|
||||
protos.Part(
|
||||
function_response=protos.FunctionResponse(
|
||||
name=tool_name, response=function_response
|
||||
tool_calls.append(
|
||||
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||
)
|
||||
|
||||
chat_request = _create_google_tool_response_content(
|
||||
[
|
||||
tool_response
|
||||
async for tool_response in chat_log.async_add_assistant_content(
|
||||
conversation.AssistantContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=content,
|
||||
tool_calls=tool_calls or None,
|
||||
)
|
||||
)
|
||||
)
|
||||
chat_request = protos.Content(parts=tool_responses)
|
||||
session.async_add_message(
|
||||
conversation.NativeContent(
|
||||
agent_id=user_input.agent_id,
|
||||
content=chat_request,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
if not tool_calls:
|
||||
break
|
||||
|
||||
response = intent.IntentResponse(language=user_input.language)
|
||||
response.async_set_speech(
|
||||
" ".join([part.text.strip() for part in chat_response.parts if part.text])
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=response, conversation_id=session.conversation_id
|
||||
response=response, conversation_id=chat_log.conversation_id
|
||||
)
|
||||
|
||||
async def _async_entry_update_listener(
|
||||
|
||||
@@ -78,7 +78,7 @@ class GoveeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
title=title, data={CONF_DEVICE_TYPE: device.device_type}
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids()
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||
address = discovery_info.address
|
||||
if address in current_addresses or address in self._discovered_devices:
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["habiticalib"],
|
||||
"requirements": ["habiticalib==0.3.3"]
|
||||
"requirements": ["habiticalib==0.3.4"]
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import os
|
||||
from pathlib import Path, PurePath
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from aiohasupervisor import SupervisorClient
|
||||
from aiohasupervisor.exceptions import (
|
||||
@@ -25,21 +27,31 @@ from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupManagerError,
|
||||
BackupNotFound,
|
||||
BackupReaderWriter,
|
||||
BackupReaderWriterError,
|
||||
CreateBackupEvent,
|
||||
CreateBackupStage,
|
||||
CreateBackupState,
|
||||
Folder,
|
||||
IdleEvent,
|
||||
IncorrectPasswordError,
|
||||
ManagerBackup,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
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.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.enum import try_parse_enum
|
||||
|
||||
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
|
||||
from .handler import get_supervisor_client
|
||||
@@ -47,6 +59,9 @@ from .handler import get_supervisor_client
|
||||
LOCATION_CLOUD_BACKUP = ".cloud_backup"
|
||||
LOCATION_LOCAL = ".local"
|
||||
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
|
||||
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
|
||||
# Set on backups automatically created when updating an addon
|
||||
TAG_ADDON_UPDATE = "supervisor.addon_update"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -97,7 +112,7 @@ def async_register_backup_agents_listener(
|
||||
|
||||
|
||||
def _backup_details_to_agent_backup(
|
||||
details: supervisor_backups.BackupComplete,
|
||||
details: supervisor_backups.BackupComplete, location: str | None
|
||||
) -> AgentBackup:
|
||||
"""Convert a supervisor backup details object to an agent backup."""
|
||||
homeassistant_included = details.homeassistant is not None
|
||||
@@ -109,18 +124,22 @@ def _backup_details_to_agent_backup(
|
||||
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
|
||||
for addon in details.addons
|
||||
]
|
||||
extra_metadata = details.extra or {}
|
||||
location = location or LOCATION_LOCAL
|
||||
return AgentBackup(
|
||||
addons=addons,
|
||||
backup_id=details.slug,
|
||||
database_included=database_included,
|
||||
date=details.date.isoformat(),
|
||||
date=extra_metadata.get(
|
||||
"supervisor.backup_request_date", details.date.isoformat()
|
||||
),
|
||||
extra_metadata=details.extra or {},
|
||||
folders=[Folder(folder) for folder in details.folders],
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=details.homeassistant,
|
||||
name=details.name,
|
||||
protected=details.protected,
|
||||
size=details.size_bytes,
|
||||
protected=details.location_attributes[location].protected,
|
||||
size=details.location_attributes[location].size_bytes,
|
||||
)
|
||||
|
||||
|
||||
@@ -144,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
return await self._client.backups.download_backup(
|
||||
backup_id,
|
||||
options=supervisor_backups.DownloadBackupOptions(location=self.location),
|
||||
)
|
||||
try:
|
||||
return await self._client.backups.download_backup(
|
||||
backup_id,
|
||||
options=supervisor_backups.DownloadBackupOptions(
|
||||
location=self.location
|
||||
),
|
||||
)
|
||||
except SupervisorNotFoundError as err:
|
||||
raise BackupNotFound from err
|
||||
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
@@ -158,8 +182,24 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
Not required for supervisor, the SupervisorBackupReaderWriter stores files.
|
||||
The upload will be skipped if the backup already exists in the agent's location.
|
||||
"""
|
||||
if await self.async_get_backup(backup.backup_id):
|
||||
_LOGGER.debug(
|
||||
"Backup %s already exists in location %s",
|
||||
backup.backup_id,
|
||||
self.location,
|
||||
)
|
||||
return
|
||||
stream = await open_stream()
|
||||
upload_options = supervisor_backups.UploadBackupOptions(
|
||||
location={self.location},
|
||||
filename=PurePath(suggested_backup_filename(backup)),
|
||||
)
|
||||
await self._client.backups.upload_backup(
|
||||
stream,
|
||||
upload_options,
|
||||
)
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
@@ -169,7 +209,7 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
if not backup.locations or self.location not in backup.locations:
|
||||
continue
|
||||
details = await self._client.backups.backup_info(backup.slug)
|
||||
result.append(_backup_details_to_agent_backup(details))
|
||||
result.append(_backup_details_to_agent_backup(details, self.location))
|
||||
return result
|
||||
|
||||
async def async_get_backup(
|
||||
@@ -178,10 +218,13 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
try:
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
except SupervisorNotFoundError:
|
||||
return None
|
||||
if self.location not in details.locations:
|
||||
return None
|
||||
return _backup_details_to_agent_backup(details)
|
||||
return _backup_details_to_agent_backup(details, self.location)
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Remove a backup."""
|
||||
@@ -192,10 +235,6 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
location={self.location}
|
||||
),
|
||||
)
|
||||
except SupervisorBadRequestError as err:
|
||||
if err.args[0] != "Backup does not exist":
|
||||
raise
|
||||
_LOGGER.debug("Backup %s does not exist", backup_id)
|
||||
except SupervisorNotFoundError:
|
||||
_LOGGER.debug("Backup %s does not exist", backup_id)
|
||||
|
||||
@@ -246,8 +285,45 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
for agent_id in agent_ids
|
||||
if manager.backup_agents[agent_id].domain == DOMAIN
|
||||
]
|
||||
locations = [agent.location for agent in hassio_agents]
|
||||
|
||||
# Supervisor does not support creating backups spread across multiple
|
||||
# locations, where some locations are encrypted and some are not.
|
||||
# It's inefficient to let core do all the copying so we want to let
|
||||
# supervisor handle as much as possible.
|
||||
# Therefore, we split the locations into two lists: encrypted and decrypted.
|
||||
# The longest list will be sent to supervisor, and the remaining locations
|
||||
# will be handled by async_upload_backup.
|
||||
# If the lists are the same length, it does not matter which one we send,
|
||||
# we send the encrypted list to have a well defined behavior.
|
||||
encrypted_locations: list[str | None] = []
|
||||
decrypted_locations: list[str | None] = []
|
||||
agents_settings = manager.config.data.agents
|
||||
for hassio_agent in hassio_agents:
|
||||
if password is not None:
|
||||
if agent_settings := agents_settings.get(hassio_agent.agent_id):
|
||||
if agent_settings.protected:
|
||||
encrypted_locations.append(hassio_agent.location)
|
||||
else:
|
||||
decrypted_locations.append(hassio_agent.location)
|
||||
else:
|
||||
encrypted_locations.append(hassio_agent.location)
|
||||
else:
|
||||
decrypted_locations.append(hassio_agent.location)
|
||||
_LOGGER.debug("Encrypted locations: %s", encrypted_locations)
|
||||
_LOGGER.debug("Decrypted locations: %s", decrypted_locations)
|
||||
if hassio_agents:
|
||||
if len(encrypted_locations) >= len(decrypted_locations):
|
||||
locations = encrypted_locations
|
||||
else:
|
||||
locations = decrypted_locations
|
||||
password = None
|
||||
else:
|
||||
locations = []
|
||||
locations = locations or [LOCATION_CLOUD_BACKUP]
|
||||
|
||||
date = dt_util.now().isoformat()
|
||||
extra_metadata = extra_metadata | {"supervisor.backup_request_date": date}
|
||||
filename = suggested_filename_from_name_date(backup_name, date)
|
||||
try:
|
||||
backup = await self._client.backups.partial_backup(
|
||||
supervisor_backups.PartialBackupOptions(
|
||||
@@ -257,17 +333,21 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
name=backup_name,
|
||||
password=password,
|
||||
compressed=True,
|
||||
location=locations or LOCATION_CLOUD_BACKUP,
|
||||
location=locations,
|
||||
homeassistant_exclude_database=not include_database,
|
||||
background=True,
|
||||
extra=extra_metadata,
|
||||
filename=PurePath(filename),
|
||||
)
|
||||
)
|
||||
except SupervisorError as err:
|
||||
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
|
||||
backup_task = self._hass.async_create_task(
|
||||
self._async_wait_for_backup(
|
||||
backup, remove_after_upload=not bool(locations)
|
||||
backup,
|
||||
locations,
|
||||
on_progress=on_progress,
|
||||
remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
|
||||
),
|
||||
name="backup_manager_create_backup",
|
||||
eager_start=False, # To ensure the task is not started before we return
|
||||
@@ -276,27 +356,46 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
return (NewBackup(backup_job_id=backup.job_id), backup_task)
|
||||
|
||||
async def _async_wait_for_backup(
|
||||
self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool
|
||||
self,
|
||||
backup: supervisor_backups.NewBackup,
|
||||
locations: list[str | None],
|
||||
*,
|
||||
on_progress: Callable[[CreateBackupEvent], None],
|
||||
remove_after_upload: bool,
|
||||
) -> WrittenBackup:
|
||||
"""Wait for a backup to complete."""
|
||||
backup_complete = asyncio.Event()
|
||||
backup_id: str | None = None
|
||||
create_errors: list[dict[str, str]] = []
|
||||
|
||||
@callback
|
||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||
"""Handle backup progress."""
|
||||
nonlocal backup_id
|
||||
if not (stage := try_parse_enum(CreateBackupStage, data.get("stage"))):
|
||||
_LOGGER.debug("Unknown create stage: %s", data.get("stage"))
|
||||
else:
|
||||
on_progress(
|
||||
CreateBackupEvent(
|
||||
reason=None, stage=stage, state=CreateBackupState.IN_PROGRESS
|
||||
)
|
||||
)
|
||||
if data.get("done") is True:
|
||||
backup_id = data.get("reference")
|
||||
create_errors.extend(data.get("errors", []))
|
||||
backup_complete.set()
|
||||
|
||||
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
|
||||
try:
|
||||
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
|
||||
await self._get_job_state(backup.job_id, on_job_progress)
|
||||
await backup_complete.wait()
|
||||
finally:
|
||||
unsub()
|
||||
if not backup_id:
|
||||
raise BackupReaderWriterError("Backup failed")
|
||||
if not backup_id or create_errors:
|
||||
# We should add more specific error handling here in the future
|
||||
raise BackupReaderWriterError(
|
||||
f"Backup failed: {create_errors or 'no backup_id'}"
|
||||
)
|
||||
|
||||
async def open_backup() -> AsyncIterator[bytes]:
|
||||
try:
|
||||
@@ -327,7 +426,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
) from err
|
||||
|
||||
return WrittenBackup(
|
||||
backup=_backup_details_to_agent_backup(details),
|
||||
backup=_backup_details_to_agent_backup(details, locations[0]),
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
)
|
||||
@@ -347,20 +446,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
for agent_id in agent_ids
|
||||
if manager.backup_agents[agent_id].domain == DOMAIN
|
||||
]
|
||||
locations = {agent.location for agent in hassio_agents}
|
||||
locations = [agent.location for agent in hassio_agents]
|
||||
locations = locations or [LOCATION_CLOUD_BACKUP]
|
||||
|
||||
backup_id = await self._client.backups.upload_backup(
|
||||
stream,
|
||||
supervisor_backups.UploadBackupOptions(
|
||||
location=locations or {LOCATION_CLOUD_BACKUP}
|
||||
),
|
||||
supervisor_backups.UploadBackupOptions(location=set(locations)),
|
||||
)
|
||||
|
||||
async def open_backup() -> AsyncIterator[bytes]:
|
||||
return await self._client.backups.download_backup(backup_id)
|
||||
|
||||
async def remove_backup() -> None:
|
||||
if locations:
|
||||
if locations != [LOCATION_CLOUD_BACKUP]:
|
||||
return
|
||||
await self._client.backups.remove_backup(
|
||||
backup_id,
|
||||
@@ -372,7 +470,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
|
||||
return WrittenBackup(
|
||||
backup=_backup_details_to_agent_backup(details),
|
||||
backup=_backup_details_to_agent_backup(details, locations[0]),
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
)
|
||||
@@ -436,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
location=restore_location,
|
||||
),
|
||||
)
|
||||
except SupervisorNotFoundError as err:
|
||||
raise BackupNotFound from err
|
||||
except SupervisorBadRequestError as err:
|
||||
# Supervisor currently does not transmit machine parsable error types
|
||||
message = err.args[0]
|
||||
@@ -444,16 +544,30 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
raise HomeAssistantError(message) from err
|
||||
|
||||
restore_complete = asyncio.Event()
|
||||
restore_errors: list[dict[str, str]] = []
|
||||
|
||||
@callback
|
||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||
"""Handle backup progress."""
|
||||
"""Handle backup restore progress."""
|
||||
if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
|
||||
_LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
|
||||
else:
|
||||
on_progress(
|
||||
RestoreBackupEvent(
|
||||
reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS
|
||||
)
|
||||
)
|
||||
if data.get("done") is True:
|
||||
restore_complete.set()
|
||||
restore_errors.extend(data.get("errors", []))
|
||||
|
||||
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
|
||||
try:
|
||||
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
|
||||
await self._get_job_state(job.job_id, on_job_progress)
|
||||
await restore_complete.wait()
|
||||
if restore_errors:
|
||||
# We should add more specific error handling here in the future
|
||||
raise BackupReaderWriterError(f"Restore failed: {restore_errors}")
|
||||
finally:
|
||||
unsub()
|
||||
|
||||
@@ -463,6 +577,60 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
|
||||
) -> None:
|
||||
"""Check restore status after core restart."""
|
||||
if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)):
|
||||
_LOGGER.debug("No restore job ID found in environment")
|
||||
return
|
||||
|
||||
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
|
||||
|
||||
sent_event = False
|
||||
|
||||
@callback
|
||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||
"""Handle backup restore progress."""
|
||||
nonlocal sent_event
|
||||
|
||||
if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
|
||||
_LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
|
||||
|
||||
if data.get("done") is not True:
|
||||
if stage or not sent_event:
|
||||
sent_event = True
|
||||
on_progress(
|
||||
RestoreBackupEvent(
|
||||
reason=None,
|
||||
stage=stage,
|
||||
state=RestoreBackupState.IN_PROGRESS,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
restore_errors = data.get("errors", [])
|
||||
if restore_errors:
|
||||
_LOGGER.warning("Restore backup failed: %s", restore_errors)
|
||||
# We should add more specific error handling here in the future
|
||||
on_progress(
|
||||
RestoreBackupEvent(
|
||||
reason="unknown_error",
|
||||
stage=stage,
|
||||
state=RestoreBackupState.FAILED,
|
||||
)
|
||||
)
|
||||
else:
|
||||
on_progress(
|
||||
RestoreBackupEvent(
|
||||
reason=None, stage=stage, state=RestoreBackupState.COMPLETED
|
||||
)
|
||||
)
|
||||
on_progress(IdleEvent())
|
||||
unsub()
|
||||
|
||||
unsub = self._async_listen_job_events(restore_job_id, on_job_progress)
|
||||
try:
|
||||
await self._get_job_state(restore_job_id, on_job_progress)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err)
|
||||
unsub()
|
||||
|
||||
@callback
|
||||
def _async_listen_job_events(
|
||||
@@ -491,6 +659,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
return unsub
|
||||
|
||||
async def _get_job_state(
|
||||
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]
|
||||
) -> None:
|
||||
"""Poll a job for its state."""
|
||||
job = await self._client.jobs.get_job(UUID(job_id))
|
||||
_LOGGER.debug("Job state: %s", job)
|
||||
on_event(job.to_dict())
|
||||
|
||||
|
||||
async def _default_agent(client: SupervisorClient) -> str:
|
||||
"""Return the default agent for creating a backup."""
|
||||
@@ -515,10 +691,20 @@ async def backup_addon_before_update(
|
||||
else:
|
||||
password = None
|
||||
|
||||
def addon_update_backup_filter(
|
||||
backups: dict[str, ManagerBackup],
|
||||
) -> dict[str, ManagerBackup]:
|
||||
"""Return addon update backups."""
|
||||
return {
|
||||
backup_id: backup
|
||||
for backup_id, backup in backups.items()
|
||||
if backup.extra_metadata.get(TAG_ADDON_UPDATE) == addon
|
||||
}
|
||||
|
||||
try:
|
||||
await backup_manager.async_create_backup(
|
||||
agent_ids=[await _default_agent(client)],
|
||||
extra_metadata={"supervisor.addon_update": addon},
|
||||
extra_metadata={TAG_ADDON_UPDATE: addon},
|
||||
include_addons=[addon],
|
||||
include_all_addons=False,
|
||||
include_database=False,
|
||||
@@ -529,6 +715,14 @@ async def backup_addon_before_update(
|
||||
)
|
||||
except BackupManagerError as err:
|
||||
raise HomeAssistantError(f"Error creating backup: {err}") from err
|
||||
else:
|
||||
try:
|
||||
await backup_manager.async_delete_filtered_backups(
|
||||
include_filter=addon_update_backup_filter,
|
||||
delete_filter=lambda backups: backups,
|
||||
)
|
||||
except BackupManagerError as err:
|
||||
raise HomeAssistantError(f"Error deleting old backups: {err}") from err
|
||||
|
||||
|
||||
async def backup_core_before_update(hass: HomeAssistant) -> None:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Heicko."""
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "heicko",
|
||||
"name": "Heicko",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "motion_blinds"
|
||||
}
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
from requests import HTTPError
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import CommandKey, Option, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
@@ -21,16 +20,13 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import api
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import (
|
||||
ATTR_KEY,
|
||||
ATTR_PROGRAM,
|
||||
ATTR_UNIT,
|
||||
ATTR_VALUE,
|
||||
BSH_PAUSE,
|
||||
BSH_RESUME,
|
||||
DOMAIN,
|
||||
OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
|
||||
SERVICE_OPTION_ACTIVE,
|
||||
@@ -44,21 +40,20 @@ from .const import (
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
)
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth]
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
SERVICE_SETTING_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_KEY): str,
|
||||
vol.Required(ATTR_KEY): vol.All(
|
||||
vol.Coerce(SettingKey),
|
||||
vol.NotIn([SettingKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
||||
}
|
||||
)
|
||||
@@ -66,7 +61,10 @@ SERVICE_SETTING_SCHEMA = vol.Schema(
|
||||
SERVICE_OPTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_KEY): str,
|
||||
vol.Required(ATTR_KEY): vol.All(
|
||||
vol.Coerce(OptionKey),
|
||||
vol.NotIn([OptionKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_VALUE): vol.Any(str, int, bool),
|
||||
vol.Optional(ATTR_UNIT): str,
|
||||
}
|
||||
@@ -75,14 +73,23 @@ SERVICE_OPTION_SCHEMA = vol.Schema(
|
||||
SERVICE_PROGRAM_SCHEMA = vol.Any(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_PROGRAM): str,
|
||||
vol.Required(ATTR_KEY): str,
|
||||
vol.Required(ATTR_PROGRAM): vol.All(
|
||||
vol.Coerce(ProgramKey),
|
||||
vol.NotIn([ProgramKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_KEY): vol.All(
|
||||
vol.Coerce(OptionKey),
|
||||
vol.NotIn([OptionKey.UNKNOWN]),
|
||||
),
|
||||
vol.Required(ATTR_VALUE): vol.Any(int, str),
|
||||
vol.Optional(ATTR_UNIT): str,
|
||||
},
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): str,
|
||||
vol.Required(ATTR_PROGRAM): str,
|
||||
vol.Required(ATTR_PROGRAM): vol.All(
|
||||
vol.Coerce(ProgramKey),
|
||||
vol.NotIn([ProgramKey.UNKNOWN]),
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -99,17 +106,24 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
def _get_appliance(
|
||||
hass: HomeAssistant,
|
||||
device_id: str | None = None,
|
||||
device_entry: dr.DeviceEntry | None = None,
|
||||
entry: HomeConnectConfigEntry | None = None,
|
||||
) -> api.HomeConnectAppliance:
|
||||
"""Return a Home Connect appliance instance given a device id or a device entry."""
|
||||
if device_id is not None and device_entry is None:
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
assert device_entry, "Either a device id or a device entry must be provided"
|
||||
async def _get_client_and_ha_id(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> tuple[HomeConnectClient, str]:
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError("Device entry not found for device id")
|
||||
entry: HomeConnectConfigEntry | None = None
|
||||
for entry_id in device_entry.config_entries:
|
||||
_entry = hass.config_entries.async_get_entry(entry_id)
|
||||
assert _entry
|
||||
if _entry.domain == DOMAIN:
|
||||
entry = cast(HomeConnectConfigEntry, _entry)
|
||||
break
|
||||
if entry is None:
|
||||
raise ServiceValidationError(
|
||||
"Home Connect config entry not found for that device id"
|
||||
)
|
||||
|
||||
ha_id = next(
|
||||
(
|
||||
@@ -119,158 +133,148 @@ def _get_appliance(
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert ha_id
|
||||
|
||||
def find_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
) -> api.HomeConnectAppliance | None:
|
||||
for device in entry.runtime_data.devices:
|
||||
appliance = device.appliance
|
||||
if appliance.haId == ha_id:
|
||||
return appliance
|
||||
return None
|
||||
|
||||
if entry is None:
|
||||
for entry_id in device_entry.config_entries:
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
assert entry
|
||||
if entry.domain == DOMAIN:
|
||||
entry = cast(HomeConnectConfigEntry, entry)
|
||||
if (appliance := find_appliance(entry)) is not None:
|
||||
return appliance
|
||||
elif (appliance := find_appliance(entry)) is not None:
|
||||
return appliance
|
||||
raise ValueError(f"Appliance for device id {device_entry.id} not found")
|
||||
|
||||
|
||||
def _get_appliance_or_raise_service_validation_error(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> api.HomeConnectAppliance:
|
||||
"""Return a Home Connect appliance instance or raise a service validation error."""
|
||||
try:
|
||||
return _get_appliance(hass, device_id)
|
||||
except (ValueError, AssertionError) as err:
|
||||
if ha_id is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="appliance_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def _run_appliance_service[*_Ts](
|
||||
hass: HomeAssistant,
|
||||
appliance: api.HomeConnectAppliance,
|
||||
method: str,
|
||||
*args: *_Ts,
|
||||
error_translation_key: str,
|
||||
error_translation_placeholders: dict[str, str],
|
||||
) -> None:
|
||||
try:
|
||||
await hass.async_add_executor_job(getattr(appliance, method), *args)
|
||||
except api.HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error_translation_key,
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
**error_translation_placeholders,
|
||||
},
|
||||
) from err
|
||||
)
|
||||
return entry.runtime_data.client, ha_id
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Home Connect component."""
|
||||
|
||||
async def _async_service_program(call, method):
|
||||
async def _async_service_program(call: ServiceCall, start: bool):
|
||||
"""Execute calls to services taking a program."""
|
||||
program = call.data[ATTR_PROGRAM]
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
|
||||
options = []
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
option_key = call.data.get(ATTR_KEY)
|
||||
if option_key is not None:
|
||||
option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]}
|
||||
|
||||
option_unit = call.data.get(ATTR_UNIT)
|
||||
if option_unit is not None:
|
||||
option[ATTR_UNIT] = option_unit
|
||||
|
||||
options.append(option)
|
||||
await _run_appliance_service(
|
||||
hass,
|
||||
_get_appliance_or_raise_service_validation_error(hass, device_id),
|
||||
method,
|
||||
program,
|
||||
options,
|
||||
error_translation_key=method,
|
||||
error_translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
|
||||
},
|
||||
options = (
|
||||
[
|
||||
Option(
|
||||
option_key,
|
||||
call.data[ATTR_VALUE],
|
||||
unit=call.data.get(ATTR_UNIT),
|
||||
)
|
||||
]
|
||||
if option_key is not None
|
||||
else None
|
||||
)
|
||||
|
||||
async def _async_service_command(call, command):
|
||||
"""Execute calls to services executing a command."""
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
try:
|
||||
if start:
|
||||
await client.start_program(ha_id, program_key=program, options=options)
|
||||
else:
|
||||
await client.set_selected_program(
|
||||
ha_id, program_key=program, options=options
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program" if start else "select_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
|
||||
},
|
||||
) from err
|
||||
|
||||
appliance = _get_appliance_or_raise_service_validation_error(hass, device_id)
|
||||
await _run_appliance_service(
|
||||
hass,
|
||||
appliance,
|
||||
"execute_command",
|
||||
command,
|
||||
error_translation_key="execute_command",
|
||||
error_translation_placeholders={"command": command},
|
||||
)
|
||||
|
||||
async def _async_service_key_value(call, method):
|
||||
"""Execute calls to services taking a key and value."""
|
||||
key = call.data[ATTR_KEY]
|
||||
async def _async_service_set_program_options(call: ServiceCall, active: bool):
|
||||
"""Execute calls to services taking a program."""
|
||||
option_key = call.data[ATTR_KEY]
|
||||
value = call.data[ATTR_VALUE]
|
||||
unit = call.data.get(ATTR_UNIT)
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
await _run_appliance_service(
|
||||
hass,
|
||||
_get_appliance_or_raise_service_validation_error(hass, device_id),
|
||||
method,
|
||||
*((key, value) if unit is None else (key, value, unit)),
|
||||
error_translation_key=method,
|
||||
error_translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
|
||||
},
|
||||
)
|
||||
try:
|
||||
if active:
|
||||
await client.set_active_program_option(
|
||||
ha_id,
|
||||
option_key=option_key,
|
||||
value=value,
|
||||
unit=unit,
|
||||
)
|
||||
else:
|
||||
await client.set_selected_program_option(
|
||||
ha_id,
|
||||
option_key=option_key,
|
||||
value=value,
|
||||
unit=unit,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_options_active_program"
|
||||
if active
|
||||
else "set_options_selected_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: option_key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_service_option_active(call):
|
||||
async def _async_service_command(call: ServiceCall, command_key: CommandKey):
|
||||
"""Execute calls to services executing a command."""
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
try:
|
||||
await client.put_command(ha_id, command_key=command_key, value=True)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="execute_command",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"command": command_key.value,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_service_option_active(call: ServiceCall):
|
||||
"""Service for setting an option for an active program."""
|
||||
await _async_service_key_value(call, "set_options_active_program")
|
||||
await _async_service_set_program_options(call, True)
|
||||
|
||||
async def async_service_option_selected(call):
|
||||
async def async_service_option_selected(call: ServiceCall):
|
||||
"""Service for setting an option for a selected program."""
|
||||
await _async_service_key_value(call, "set_options_selected_program")
|
||||
await _async_service_set_program_options(call, False)
|
||||
|
||||
async def async_service_setting(call):
|
||||
async def async_service_setting(call: ServiceCall):
|
||||
"""Service for changing a setting."""
|
||||
await _async_service_key_value(call, "set_setting")
|
||||
key = call.data[ATTR_KEY]
|
||||
value = call.data[ATTR_VALUE]
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async def async_service_pause_program(call):
|
||||
try:
|
||||
await client.set_setting(ha_id, setting_key=key, value=value)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_setting",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_service_pause_program(call: ServiceCall):
|
||||
"""Service for pausing a program."""
|
||||
await _async_service_command(call, BSH_PAUSE)
|
||||
await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
|
||||
|
||||
async def async_service_resume_program(call):
|
||||
async def async_service_resume_program(call: ServiceCall):
|
||||
"""Service for resuming a paused program."""
|
||||
await _async_service_command(call, BSH_RESUME)
|
||||
await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
|
||||
|
||||
async def async_service_select_program(call):
|
||||
async def async_service_select_program(call: ServiceCall):
|
||||
"""Service for selecting a program."""
|
||||
await _async_service_program(call, "select_program")
|
||||
await _async_service_program(call, False)
|
||||
|
||||
async def async_service_start_program(call):
|
||||
async def async_service_start_program(call: ServiceCall):
|
||||
"""Service for starting a program."""
|
||||
await _async_service_program(call, "start_program")
|
||||
await _async_service_program(call, True)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -323,12 +327,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
|
||||
)
|
||||
)
|
||||
|
||||
entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
await update_all_devices(hass, entry)
|
||||
config_entry_auth = AsyncConfigEntryAuth(hass, session)
|
||||
|
||||
home_connect_client = HomeConnectClient(config_entry_auth)
|
||||
|
||||
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.runtime_data.start_event_listener()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -339,21 +352,6 @@ async def async_unload_entry(
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@Throttle(SCAN_INTERVAL)
|
||||
async def update_all_devices(
|
||||
hass: HomeAssistant, entry: HomeConnectConfigEntry
|
||||
) -> None:
|
||||
"""Update all the devices."""
|
||||
hc_api = entry.runtime_data
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(hc_api.get_devices)
|
||||
for device in hc_api.devices:
|
||||
await hass.async_add_executor_job(device.initialize)
|
||||
except HTTPError as err:
|
||||
_LOGGER.warning("Cannot update devices: %s", err.response.status_code)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: HomeConnectConfigEntry
|
||||
) -> bool:
|
||||
@@ -382,25 +380,3 @@ async def async_migrate_entry(
|
||||
|
||||
_LOGGER.debug("Migration to version %s successful", entry.version)
|
||||
return True
|
||||
|
||||
|
||||
def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]:
|
||||
"""Return a dict from a Home Connect error."""
|
||||
return {
|
||||
"description": cast(dict[str, Any], err.args[0]).get("description", "?")
|
||||
if len(err.args) > 0 and isinstance(err.args[0], dict)
|
||||
else err.args[0]
|
||||
if len(err.args) > 0 and isinstance(err.args[0], str)
|
||||
else "?",
|
||||
}
|
||||
|
||||
|
||||
def bsh_key_to_translation_key(bsh_key: str) -> str:
|
||||
"""Convert a BSH key to a translation key format.
|
||||
|
||||
This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
|
||||
and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
|
||||
"""
|
||||
return "_".join(
|
||||
RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
|
||||
).lower()
|
||||
|
||||
@@ -1,85 +1,28 @@
|
||||
"""API for Home Connect bound to HASS OAuth."""
|
||||
|
||||
from asyncio import run_coroutine_threadsafe
|
||||
import logging
|
||||
from aiohomeconnect.client import AbstractAuth
|
||||
from aiohomeconnect.const import API_ENDPOINT
|
||||
|
||||
import homeconnect
|
||||
from homeconnect.api import HomeConnectAppliance, HomeConnectError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
|
||||
class ConfigEntryAuth(homeconnect.HomeConnectAPI):
|
||||
class AsyncConfigEntryAuth(AbstractAuth):
|
||||
"""Provide Home Connect authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize Home Connect Auth."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.session = config_entry_oauth2_flow.OAuth2Session(
|
||||
hass, config_entry, implementation
|
||||
)
|
||||
super().__init__(self.session.token)
|
||||
self.devices: list[HomeConnectDevice] = []
|
||||
super().__init__(get_async_client(hass), host=API_ENDPOINT)
|
||||
self.session = oauth_session
|
||||
|
||||
def refresh_tokens(self) -> dict:
|
||||
"""Refresh and return new Home Connect tokens using Home Assistant OAuth2 session."""
|
||||
run_coroutine_threadsafe(
|
||||
self.session.async_ensure_token_valid(), self.hass.loop
|
||||
).result()
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
await self.session.async_ensure_token_valid()
|
||||
|
||||
return self.session.token
|
||||
|
||||
def get_devices(self) -> list[HomeConnectAppliance]:
|
||||
"""Get a dictionary of devices."""
|
||||
appl: list[HomeConnectAppliance] = self.get_appliances()
|
||||
self.devices = [HomeConnectDevice(self.hass, app) for app in appl]
|
||||
return self.devices
|
||||
|
||||
|
||||
class HomeConnectDevice:
|
||||
"""Generic Home Connect device."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None:
|
||||
"""Initialize the device class."""
|
||||
self.hass = hass
|
||||
self.appliance = appliance
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Fetch the info needed to initialize the device."""
|
||||
try:
|
||||
self.appliance.get_status()
|
||||
except (HomeConnectError, ValueError):
|
||||
_LOGGER.debug("Unable to fetch appliance status. Probably offline")
|
||||
try:
|
||||
self.appliance.get_settings()
|
||||
except (HomeConnectError, ValueError):
|
||||
_LOGGER.debug("Unable to fetch settings. Probably offline")
|
||||
try:
|
||||
program_active = self.appliance.get_programs_active()
|
||||
except (HomeConnectError, ValueError):
|
||||
_LOGGER.debug("Unable to fetch active programs. Probably offline")
|
||||
program_active = None
|
||||
if program_active and ATTR_KEY in program_active:
|
||||
self.appliance.status[BSH_ACTIVE_PROGRAM] = {
|
||||
ATTR_VALUE: program_active[ATTR_KEY]
|
||||
}
|
||||
self.appliance.listen_events(callback=self.event_callback)
|
||||
|
||||
def event_callback(self, appliance: HomeConnectAppliance) -> None:
|
||||
"""Handle event."""
|
||||
_LOGGER.debug("Update triggered on %s", appliance.name)
|
||||
_LOGGER.debug(self.appliance.status)
|
||||
dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId)
|
||||
return self.session.token["access_token"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Application credentials platform for Home Connect."""
|
||||
|
||||
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Provides a binary sensor for Home Connect."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import StatusKey
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -19,26 +21,22 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from . import HomeConnectConfigEntry
|
||||
from .api import HomeConnectDevice
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import (
|
||||
ATTR_VALUE,
|
||||
BSH_DOOR_STATE,
|
||||
BSH_DOOR_STATE_CLOSED,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
BSH_REMOTE_CONTROL_ACTIVATION_STATE,
|
||||
BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
DOMAIN,
|
||||
REFRIGERATION_STATUS_DOOR_CHILLER,
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED,
|
||||
REFRIGERATION_STATUS_DOOR_FREEZER,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN,
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
)
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REFRIGERATION_DOOR_BOOLEAN_MAP = {
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED: False,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN: True,
|
||||
@@ -54,19 +52,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
BINARY_SENSORS = (
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=BSH_REMOTE_CONTROL_ACTIVATION_STATE,
|
||||
key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
|
||||
translation_key="remote_control",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
|
||||
translation_key="remote_start",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="BSH.Common.Status.LocalControlActive",
|
||||
key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE,
|
||||
translation_key="local_control",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="BSH.Common.Status.BatteryChargingState",
|
||||
key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE,
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
boolean_map={
|
||||
"BSH.Common.EnumType.BatteryChargingState.Charging": True,
|
||||
@@ -75,7 +73,7 @@ BINARY_SENSORS = (
|
||||
translation_key="battery_charging_state",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="BSH.Common.Status.ChargingConnection",
|
||||
key=StatusKey.BSH_COMMON_CHARGING_CONNECTION,
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
boolean_map={
|
||||
"BSH.Common.EnumType.ChargingConnection.Connected": True,
|
||||
@@ -84,31 +82,31 @@ BINARY_SENSORS = (
|
||||
translation_key="charging_connection",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
|
||||
translation_key="dust_box_inserted",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.Lifted",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED,
|
||||
translation_key="lifted",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.Lost",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST,
|
||||
translation_key="lost",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=REFRIGERATION_STATUS_DOOR_CHILLER,
|
||||
key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON,
|
||||
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
translation_key="chiller_door",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=REFRIGERATION_STATUS_DOOR_FREEZER,
|
||||
key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
|
||||
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
translation_key="freezer_door",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
|
||||
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
translation_key="refrigerator_door",
|
||||
@@ -116,26 +114,33 @@ BINARY_SENSORS = (
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
entities.extend(
|
||||
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
|
||||
for description in BINARY_SENSORS
|
||||
if description.key in appliance.status
|
||||
)
|
||||
if StatusKey.BSH_COMMON_DOOR_STATE in appliance.status:
|
||||
entities.append(HomeConnectDoorBinarySensor(entry.runtime_data, appliance))
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect binary sensor."""
|
||||
|
||||
def get_entities() -> list[BinarySensorEntity]:
|
||||
entities: list[BinarySensorEntity] = []
|
||||
for device in entry.runtime_data.devices:
|
||||
entities.extend(
|
||||
HomeConnectBinarySensor(device, description)
|
||||
for description in BINARY_SENSORS
|
||||
if description.key in device.appliance.status
|
||||
)
|
||||
if BSH_DOOR_STATE in device.appliance.status:
|
||||
entities.append(HomeConnectDoorBinarySensor(device))
|
||||
return entities
|
||||
|
||||
async_add_entities(await hass.async_add_executor_job(get_entities), True)
|
||||
setup_home_connect_entry(
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
||||
@@ -143,25 +148,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: HomeConnectBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if the binary sensor is available."""
|
||||
return self._attr_is_on is not None
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the binary sensor's status."""
|
||||
if not self.device.appliance.status or not (
|
||||
status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
|
||||
):
|
||||
self._attr_is_on = None
|
||||
return
|
||||
if self.entity_description.boolean_map:
|
||||
self._attr_is_on = self.entity_description.boolean_map.get(status)
|
||||
elif status not in [True, False]:
|
||||
self._attr_is_on = None
|
||||
else:
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the native value of the binary sensor."""
|
||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||
if isinstance(status, bool):
|
||||
self._attr_is_on = status
|
||||
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
|
||||
elif self.entity_description.boolean_map:
|
||||
self._attr_is_on = self.entity_description.boolean_map.get(status)
|
||||
else:
|
||||
self._attr_is_on = None
|
||||
|
||||
|
||||
class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
@@ -171,13 +166,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: HomeConnectDevice,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
device,
|
||||
coordinator,
|
||||
appliance,
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=BSH_DOOR_STATE,
|
||||
key=StatusKey.BSH_COMMON_DOOR_STATE,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
boolean_map={
|
||||
BSH_DOOR_STATE_CLOSED: False,
|
||||
@@ -186,8 +183,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
},
|
||||
),
|
||||
)
|
||||
self._attr_unique_id = f"{device.appliance.haId}-Door"
|
||||
self._attr_name = f"{device.appliance.name} Door"
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-Door"
|
||||
self._attr_name = f"{appliance.info.name} Door"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
@@ -234,6 +231,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Common callbacks for all Home Connect platforms."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey
|
||||
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
|
||||
def _handle_paired_or_connected_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Handle a new paired appliance or an appliance that has been connected.
|
||||
|
||||
This function is used to handle connected events also, because some appliances
|
||||
don't report any data while they are off because they disconnect themselves
|
||||
when they are turned off, so we need to check if the entities have been added
|
||||
already or it is the first time we see them when the appliance is connected.
|
||||
"""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = [
|
||||
entity
|
||||
for entity in get_entities_for_appliance(entry, appliance)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
for entity in entities_to_add
|
||||
}
|
||||
)
|
||||
entities.extend(entities_to_add)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
def _handle_depaired_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
) -> None:
|
||||
"""Handle a removed appliance."""
|
||||
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
|
||||
if appliance_id not in entry.runtime_data.data:
|
||||
known_entity_unique_ids.pop(entity_unique_id, None)
|
||||
|
||||
|
||||
def setup_home_connect_entry(
|
||||
entry: HomeConnectConfigEntry,
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the callbacks for paired and depaired appliances."""
|
||||
known_entity_unique_ids: dict[str, str] = {}
|
||||
|
||||
entities: list[HomeConnectEntity] = []
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = get_entities_for_appliance(entry, appliance)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
for entity in entities_to_add
|
||||
}
|
||||
)
|
||||
entities.extend(entities_to_add)
|
||||
async_add_entities(entities)
|
||||
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(
|
||||
_handle_paired_or_connected_appliance,
|
||||
entry,
|
||||
known_entity_unique_ids,
|
||||
get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
(
|
||||
EventKey.BSH_COMMON_APPLIANCE_PAIRED,
|
||||
EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
|
||||
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
|
||||
)
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user