mirror of
https://github.com/home-assistant/core.git
synced 2025-08-07 04:28:19 +00:00
Merge branch 'home-assistant:dev' into homee-switch
This commit is contained in:
commit
3299bb914a
@ -8,7 +8,7 @@ repos:
|
|||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
|
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.3.0
|
rev: v2.4.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
args:
|
args:
|
||||||
|
6
CODEOWNERS
generated
6
CODEOWNERS
generated
@ -731,6 +731,8 @@ build.json @home-assistant/supervisor
|
|||||||
/homeassistant/components/intent/ @home-assistant/core @synesthesiam
|
/homeassistant/components/intent/ @home-assistant/core @synesthesiam
|
||||||
/tests/components/intent/ @home-assistant/core @synesthesiam
|
/tests/components/intent/ @home-assistant/core @synesthesiam
|
||||||
/homeassistant/components/intesishome/ @jnimmo
|
/homeassistant/components/intesishome/ @jnimmo
|
||||||
|
/homeassistant/components/iometer/ @MaestroOnICe
|
||||||
|
/tests/components/iometer/ @MaestroOnICe
|
||||||
/homeassistant/components/ios/ @robbiet480
|
/homeassistant/components/ios/ @robbiet480
|
||||||
/tests/components/ios/ @robbiet480
|
/tests/components/ios/ @robbiet480
|
||||||
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
|
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
|
||||||
@ -765,8 +767,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/ituran/ @shmuelzon
|
/tests/components/ituran/ @shmuelzon
|
||||||
/homeassistant/components/izone/ @Swamp-Ig
|
/homeassistant/components/izone/ @Swamp-Ig
|
||||||
/tests/components/izone/ @Swamp-Ig
|
/tests/components/izone/ @Swamp-Ig
|
||||||
/homeassistant/components/jellyfin/ @j-stienstra @ctalkington
|
/homeassistant/components/jellyfin/ @RunC0deRun @ctalkington
|
||||||
/tests/components/jellyfin/ @j-stienstra @ctalkington
|
/tests/components/jellyfin/ @RunC0deRun @ctalkington
|
||||||
/homeassistant/components/jewish_calendar/ @tsvi
|
/homeassistant/components/jewish_calendar/ @tsvi
|
||||||
/tests/components/jewish_calendar/ @tsvi
|
/tests/components/jewish_calendar/ @tsvi
|
||||||
/homeassistant/components/juicenet/ @jesserockz
|
/homeassistant/components/juicenet/ @jesserockz
|
||||||
|
2
Dockerfile
generated
2
Dockerfile
generated
@ -13,7 +13,7 @@ ENV \
|
|||||||
ARG QEMU_CPU
|
ARG QEMU_CPU
|
||||||
|
|
||||||
# Install uv
|
# Install uv
|
||||||
RUN pip3 install uv==0.5.21
|
RUN pip3 install uv==0.5.27
|
||||||
|
|
||||||
WORKDIR /usr/src
|
WORKDIR /usr/src
|
||||||
|
|
||||||
|
@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self.async_create_entry(title=discovery.name, data={})
|
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):
|
for discovery_info in async_discovered_service_info(self.hass):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -7,6 +7,6 @@
|
|||||||
"integration_type": "service",
|
"integration_type": "service",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["python_homeassistant_analytics"],
|
"loggers": ["python_homeassistant_analytics"],
|
||||||
"requirements": ["python-homeassistant-analytics==0.8.1"],
|
"requirements": ["python-homeassistant-analytics==0.9.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@ -272,6 +272,7 @@ class AnthropicConversationEntity(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
tool_input = llm.ToolInput(
|
tool_input = llm.ToolInput(
|
||||||
|
id=tool_call.id,
|
||||||
tool_name=tool_call.name,
|
tool_name=tool_call.name,
|
||||||
tool_args=cast(dict[str, Any], tool_call.input),
|
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
|
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
|
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
|
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
|
assert self.atv
|
||||||
all_identifiers = set(self.atv.all_identifiers)
|
all_identifiers = set(self.atv.all_identifiers)
|
||||||
|
@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=self._discovered_devices[address][0], data={}
|
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):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -19,5 +19,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/aranet",
|
"documentation": "https://www.home-assistant.io/integrations/aranet",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["aranet4==2.5.0"]
|
"requirements": ["aranet4==2.5.1"]
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import voluptuous as vol
|
|||||||
|
|
||||||
from homeassistant.components import stt
|
from homeassistant.components import stt
|
||||||
from homeassistant.core import Context, HomeAssistant
|
from homeassistant.core import Context, HomeAssistant
|
||||||
|
from homeassistant.helpers import chat_session
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream(
|
|||||||
|
|
||||||
Raises PipelineNotFound if no pipeline is found.
|
Raises PipelineNotFound if no pipeline is found.
|
||||||
"""
|
"""
|
||||||
pipeline_input = PipelineInput(
|
with chat_session.async_get_chat_session(hass, conversation_id) as session:
|
||||||
conversation_id=conversation_id,
|
pipeline_input = PipelineInput(
|
||||||
device_id=device_id,
|
conversation_id=session.conversation_id,
|
||||||
stt_metadata=stt_metadata,
|
device_id=device_id,
|
||||||
stt_stream=stt_stream,
|
stt_metadata=stt_metadata,
|
||||||
wake_word_phrase=wake_word_phrase,
|
stt_stream=stt_stream,
|
||||||
conversation_extra_system_prompt=conversation_extra_system_prompt,
|
wake_word_phrase=wake_word_phrase,
|
||||||
run=PipelineRun(
|
conversation_extra_system_prompt=conversation_extra_system_prompt,
|
||||||
hass,
|
run=PipelineRun(
|
||||||
context=context,
|
hass,
|
||||||
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
|
context=context,
|
||||||
start_stage=start_stage,
|
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
|
||||||
end_stage=end_stage,
|
start_stage=start_stage,
|
||||||
event_callback=event_callback,
|
end_stage=end_stage,
|
||||||
tts_audio_output=tts_audio_output,
|
event_callback=event_callback,
|
||||||
wake_word_settings=wake_word_settings,
|
tts_audio_output=tts_audio_output,
|
||||||
audio_settings=audio_settings or AudioSettings(),
|
wake_word_settings=wake_word_settings,
|
||||||
),
|
audio_settings=audio_settings or AudioSettings(),
|
||||||
)
|
),
|
||||||
await pipeline_input.validate()
|
)
|
||||||
await pipeline_input.execute()
|
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.const import MATCH_ALL
|
||||||
from homeassistant.core import Context, HomeAssistant, callback
|
from homeassistant.core import Context, HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import intent
|
from homeassistant.helpers import chat_session, intent
|
||||||
from homeassistant.helpers.collection import (
|
from homeassistant.helpers.collection import (
|
||||||
CHANGE_UPDATED,
|
CHANGE_UPDATED,
|
||||||
CollectionError,
|
CollectionError,
|
||||||
@ -624,7 +624,7 @@ class PipelineRun:
|
|||||||
return
|
return
|
||||||
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event)
|
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."""
|
"""Emit run start event."""
|
||||||
self._device_id = device_id
|
self._device_id = device_id
|
||||||
self._start_debug_recording_thread()
|
self._start_debug_recording_thread()
|
||||||
@ -632,6 +632,7 @@ class PipelineRun:
|
|||||||
data = {
|
data = {
|
||||||
"pipeline": self.pipeline.id,
|
"pipeline": self.pipeline.id,
|
||||||
"language": self.language,
|
"language": self.language,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
}
|
}
|
||||||
if self.runner_data is not None:
|
if self.runner_data is not None:
|
||||||
data["runner_data"] = self.runner_data
|
data["runner_data"] = self.runner_data
|
||||||
@ -1015,7 +1016,7 @@ class PipelineRun:
|
|||||||
async def recognize_intent(
|
async def recognize_intent(
|
||||||
self,
|
self,
|
||||||
intent_input: str,
|
intent_input: str,
|
||||||
conversation_id: str | None,
|
conversation_id: str,
|
||||||
device_id: str | None,
|
device_id: str | None,
|
||||||
conversation_extra_system_prompt: str | None,
|
conversation_extra_system_prompt: str | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
@ -1063,11 +1064,11 @@ class PipelineRun:
|
|||||||
agent_id=self.intent_agent,
|
agent_id=self.intent_agent,
|
||||||
extra_system_prompt=conversation_extra_system_prompt,
|
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
|
intent_response: intent.IntentResponse | None = None
|
||||||
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
|
if not processed_locally:
|
||||||
# Sentence triggers override conversation agent
|
# Sentence triggers override conversation agent
|
||||||
if (
|
if (
|
||||||
trigger_response_text
|
trigger_response_text
|
||||||
@ -1094,22 +1095,26 @@ class PipelineRun:
|
|||||||
|
|
||||||
# It was already handled, create response and add to chat history
|
# It was already handled, create response and add to chat history
|
||||||
if intent_response is not None:
|
if intent_response is not None:
|
||||||
async with conversation.async_get_chat_session(
|
with (
|
||||||
self.hass, user_input
|
chat_session.async_get_chat_session(
|
||||||
) as 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: str = intent_response.speech.get("plain", {}).get(
|
||||||
"speech", ""
|
"speech", ""
|
||||||
)
|
)
|
||||||
chat_session.async_add_message(
|
chat_log.async_add_assistant_content_without_tools(
|
||||||
conversation.Content(
|
conversation.AssistantContent(
|
||||||
role="assistant",
|
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
content=speech,
|
content=speech,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
conversation_result = conversation.ConversationResult(
|
conversation_result = conversation.ConversationResult(
|
||||||
response=intent_response,
|
response=intent_response,
|
||||||
conversation_id=chat_session.conversation_id,
|
conversation_id=session.conversation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@ -1404,12 +1409,15 @@ def _pipeline_debug_recording_thread_proc(
|
|||||||
wav_writer.close()
|
wav_writer.close()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(kw_only=True)
|
||||||
class PipelineInput:
|
class PipelineInput:
|
||||||
"""Input to a pipeline run."""
|
"""Input to a pipeline run."""
|
||||||
|
|
||||||
run: PipelineRun
|
run: PipelineRun
|
||||||
|
|
||||||
|
conversation_id: str
|
||||||
|
"""Identifier for the conversation."""
|
||||||
|
|
||||||
stt_metadata: stt.SpeechMetadata | None = None
|
stt_metadata: stt.SpeechMetadata | None = None
|
||||||
"""Metadata of stt input audio. Required when start_stage = stt."""
|
"""Metadata of stt input audio. Required when start_stage = stt."""
|
||||||
|
|
||||||
@ -1425,9 +1433,6 @@ class PipelineInput:
|
|||||||
tts_input: str | None = None
|
tts_input: str | None = None
|
||||||
"""Input for text-to-speech. Required when start_stage = tts."""
|
"""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
|
conversation_extra_system_prompt: str | None = None
|
||||||
"""Extra prompt information for the conversation agent."""
|
"""Extra prompt information for the conversation agent."""
|
||||||
|
|
||||||
@ -1436,7 +1441,7 @@ class PipelineInput:
|
|||||||
|
|
||||||
async def execute(self) -> None:
|
async def execute(self) -> None:
|
||||||
"""Run pipeline."""
|
"""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
|
current_stage: PipelineStage | None = self.run.start_stage
|
||||||
stt_audio_buffer: list[EnhancedAudioChunk] = []
|
stt_audio_buffer: list[EnhancedAudioChunk] = []
|
||||||
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None
|
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.components import conversation, stt, tts, websocket_api
|
||||||
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
|
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
|
||||||
from homeassistant.core import HomeAssistant, callback
|
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 homeassistant.util import language as language_util
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -145,7 +149,6 @@ async def websocket_run(
|
|||||||
|
|
||||||
# Arguments to PipelineInput
|
# Arguments to PipelineInput
|
||||||
input_args: dict[str, Any] = {
|
input_args: dict[str, Any] = {
|
||||||
"conversation_id": msg.get("conversation_id"),
|
|
||||||
"device_id": msg.get("device_id"),
|
"device_id": msg.get("device_id"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -233,38 +236,42 @@ async def websocket_run(
|
|||||||
audio_settings=audio_settings or AudioSettings(),
|
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:
|
try:
|
||||||
await pipeline_input.validate()
|
await pipeline_input.validate()
|
||||||
except PipelineError as error:
|
except PipelineError as error:
|
||||||
# Report more specific error when possible
|
# Report more specific error when possible
|
||||||
connection.send_error(msg["id"], error.code, error.message)
|
connection.send_error(msg["id"], error.code, error.message)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Confirm subscription
|
# Confirm subscription
|
||||||
connection.send_result(msg["id"])
|
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
|
# Cancel pipeline if user unsubscribes
|
||||||
connection.subscriptions[msg["id"]] = run_task.cancel
|
connection.subscriptions[msg["id"]] = run_task.cancel
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Task contains a timeout
|
# Task contains a timeout
|
||||||
async with asyncio.timeout(timeout):
|
async with asyncio.timeout(timeout):
|
||||||
await run_task
|
await run_task
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
pipeline_input.run.process_event(
|
pipeline_input.run.process_event(
|
||||||
PipelineEvent(
|
PipelineEvent(
|
||||||
PipelineEventType.ERROR,
|
PipelineEventType.ERROR,
|
||||||
{"code": "timeout", "message": "Timeout running pipeline"},
|
{"code": "timeout", "message": "Timeout running pipeline"},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
finally:
|
||||||
finally:
|
if unregister_handler is not None:
|
||||||
if unregister_handler is not None:
|
# Unregister binary handler
|
||||||
# Unregister binary handler
|
unregister_handler()
|
||||||
unregister_handler()
|
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
|
@ -8,7 +8,7 @@ from dataclasses import dataclass
|
|||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any, Final, Literal, final
|
from typing import Any, Literal, final
|
||||||
|
|
||||||
from homeassistant.components import conversation, media_source, stt, tts
|
from homeassistant.components import conversation, media_source, stt, tts
|
||||||
from homeassistant.components.assist_pipeline import (
|
from homeassistant.components.assist_pipeline import (
|
||||||
@ -28,14 +28,12 @@ from homeassistant.components.tts import (
|
|||||||
)
|
)
|
||||||
from homeassistant.core import Context, callback
|
from homeassistant.core import Context, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers import entity
|
from homeassistant.helpers import chat_session, entity
|
||||||
from homeassistant.helpers.entity import EntityDescription
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
|
||||||
from .const import AssistSatelliteEntityFeature
|
from .const import AssistSatelliteEntityFeature
|
||||||
from .errors import AssistSatelliteError, SatelliteBusyError
|
from .errors import AssistSatelliteError, SatelliteBusyError
|
||||||
|
|
||||||
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
|
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -114,7 +112,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
_attr_vad_sensitivity_entity_id: str | None = None
|
_attr_vad_sensitivity_entity_id: str | None = None
|
||||||
|
|
||||||
_conversation_id: str | None = None
|
_conversation_id: str | None = None
|
||||||
_conversation_id_time: float | None = None
|
|
||||||
|
|
||||||
_run_has_tts: bool = False
|
_run_has_tts: bool = False
|
||||||
_is_announcing = False
|
_is_announcing = False
|
||||||
@ -260,8 +257,27 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
else:
|
else:
|
||||||
self._extra_system_prompt = start_message or None
|
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:
|
try:
|
||||||
await self.async_start_conversation(announcement)
|
await self.async_start_conversation(announcement)
|
||||||
|
except Exception:
|
||||||
|
# Clear prompt on error
|
||||||
|
self._conversation_id = None
|
||||||
|
self._extra_system_prompt = None
|
||||||
|
raise
|
||||||
finally:
|
finally:
|
||||||
self._is_announcing = False
|
self._is_announcing = False
|
||||||
|
|
||||||
@ -325,51 +341,52 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
|
|
||||||
assert self._context is not None
|
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
|
# Set entity state based on pipeline events
|
||||||
self._run_has_tts = False
|
self._run_has_tts = False
|
||||||
|
|
||||||
assert self.platform.config_entry is not None
|
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,
|
|
||||||
conversation_extra_system_prompt=extra_system_prompt,
|
|
||||||
),
|
|
||||||
f"{self.entity_id}_pipeline",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
with chat_session.async_get_chat_session(
|
||||||
await self._pipeline_task
|
self.hass, self._conversation_id
|
||||||
finally:
|
) as session:
|
||||||
self._pipeline_task = None
|
# 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:
|
async def _cancel_running_pipeline(self) -> None:
|
||||||
"""Cancel the current pipeline if it's running."""
|
"""Cancel the current pipeline if it's running."""
|
||||||
@ -393,11 +410,6 @@ class AssistSatelliteEntity(entity.Entity):
|
|||||||
self._set_state(AssistSatelliteState.LISTENING)
|
self._set_state(AssistSatelliteState.LISTENING)
|
||||||
elif event.type is PipelineEventType.INTENT_START:
|
elif event.type is PipelineEventType.INTENT_START:
|
||||||
self._set_state(AssistSatelliteState.PROCESSING)
|
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:
|
elif event.type is PipelineEventType.TTS_START:
|
||||||
# Wait until tts_response_finished is called to return to waiting state
|
# Wait until tts_response_finished is called to return to waiting state
|
||||||
self._run_has_tts = True
|
self._run_has_tts = True
|
||||||
|
@ -28,5 +28,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["pubnub", "yalexs"],
|
"loggers": ["pubnub", "yalexs"],
|
||||||
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"]
|
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"]
|
||||||
}
|
}
|
||||||
|
@ -26,15 +26,18 @@ from .manager import (
|
|||||||
BackupReaderWriterError,
|
BackupReaderWriterError,
|
||||||
CoreBackupReaderWriter,
|
CoreBackupReaderWriter,
|
||||||
CreateBackupEvent,
|
CreateBackupEvent,
|
||||||
|
CreateBackupStage,
|
||||||
|
CreateBackupState,
|
||||||
IdleEvent,
|
IdleEvent,
|
||||||
IncorrectPasswordError,
|
IncorrectPasswordError,
|
||||||
ManagerBackup,
|
ManagerBackup,
|
||||||
NewBackup,
|
NewBackup,
|
||||||
RestoreBackupEvent,
|
RestoreBackupEvent,
|
||||||
|
RestoreBackupStage,
|
||||||
RestoreBackupState,
|
RestoreBackupState,
|
||||||
WrittenBackup,
|
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 .util import suggested_filename, suggested_filename_from_name_date
|
||||||
from .websocket import async_register_websocket_handlers
|
from .websocket import async_register_websocket_handlers
|
||||||
|
|
||||||
@ -45,10 +48,13 @@ __all__ = [
|
|||||||
"BackupAgentError",
|
"BackupAgentError",
|
||||||
"BackupAgentPlatformProtocol",
|
"BackupAgentPlatformProtocol",
|
||||||
"BackupManagerError",
|
"BackupManagerError",
|
||||||
|
"BackupNotFound",
|
||||||
"BackupPlatformProtocol",
|
"BackupPlatformProtocol",
|
||||||
"BackupReaderWriter",
|
"BackupReaderWriter",
|
||||||
"BackupReaderWriterError",
|
"BackupReaderWriterError",
|
||||||
"CreateBackupEvent",
|
"CreateBackupEvent",
|
||||||
|
"CreateBackupStage",
|
||||||
|
"CreateBackupState",
|
||||||
"Folder",
|
"Folder",
|
||||||
"IdleEvent",
|
"IdleEvent",
|
||||||
"IncorrectPasswordError",
|
"IncorrectPasswordError",
|
||||||
@ -56,6 +62,7 @@ __all__ = [
|
|||||||
"ManagerBackup",
|
"ManagerBackup",
|
||||||
"NewBackup",
|
"NewBackup",
|
||||||
"RestoreBackupEvent",
|
"RestoreBackupEvent",
|
||||||
|
"RestoreBackupStage",
|
||||||
"RestoreBackupState",
|
"RestoreBackupState",
|
||||||
"WrittenBackup",
|
"WrittenBackup",
|
||||||
"async_get_manager",
|
"async_get_manager",
|
||||||
|
@ -11,13 +11,7 @@ from propcache.api import cached_property
|
|||||||
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
|
|
||||||
from .models import AgentBackup, BackupError
|
from .models import AgentBackup, BackupAgentError
|
||||||
|
|
||||||
|
|
||||||
class BackupAgentError(BackupError):
|
|
||||||
"""Base class for backup agent errors."""
|
|
||||||
|
|
||||||
error_code = "backup_agent_error"
|
|
||||||
|
|
||||||
|
|
||||||
class BackupAgentUnreachableError(BackupAgentError):
|
class BackupAgentUnreachableError(BackupAgentError):
|
||||||
@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError):
|
|||||||
_message = "The backup agent is unreachable."
|
_message = "The backup agent is unreachable."
|
||||||
|
|
||||||
|
|
||||||
class BackupNotFound(BackupAgentError):
|
|
||||||
"""Raised when a backup is not found."""
|
|
||||||
|
|
||||||
error_code = "backup_not_found"
|
|
||||||
|
|
||||||
|
|
||||||
class BackupAgent(abc.ABC):
|
class BackupAgent(abc.ABC):
|
||||||
"""Backup agent interface."""
|
"""Backup agent interface."""
|
||||||
|
|
||||||
|
@ -11,9 +11,9 @@ from typing import Any
|
|||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.hassio import is_hassio
|
from homeassistant.helpers.hassio import is_hassio
|
||||||
|
|
||||||
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
|
from .agent import BackupAgent, LocalBackupAgent
|
||||||
from .const import DOMAIN, LOGGER
|
from .const import DOMAIN, LOGGER
|
||||||
from .models import AgentBackup
|
from .models import AgentBackup, BackupNotFound
|
||||||
from .util import read_backup, suggested_filename
|
from .util import read_backup, suggested_filename
|
||||||
|
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ from . import util
|
|||||||
from .agent import BackupAgent
|
from .agent import BackupAgent
|
||||||
from .const import DATA_MANAGER
|
from .const import DATA_MANAGER
|
||||||
from .manager import BackupManager
|
from .manager import BackupManager
|
||||||
|
from .models import BackupNotFound
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView):
|
|||||||
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
|
||||||
}
|
}
|
||||||
|
|
||||||
if not password or not backup.protected:
|
try:
|
||||||
return await self._send_backup_no_password(
|
if not password or not backup.protected:
|
||||||
request, headers, backup_id, agent_id, agent, manager
|
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(
|
except BackupNotFound:
|
||||||
hass, request, headers, backup_id, agent_id, password, agent, manager
|
return Response(status=HTTPStatus.NOT_FOUND)
|
||||||
)
|
|
||||||
|
|
||||||
async def _send_backup_no_password(
|
async def _send_backup_no_password(
|
||||||
self,
|
self,
|
||||||
|
@ -50,7 +50,14 @@ from .const import (
|
|||||||
EXCLUDE_FROM_BACKUP,
|
EXCLUDE_FROM_BACKUP,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
)
|
)
|
||||||
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
|
from .models import (
|
||||||
|
AgentBackup,
|
||||||
|
BackupError,
|
||||||
|
BackupManagerError,
|
||||||
|
BackupReaderWriterError,
|
||||||
|
BaseBackup,
|
||||||
|
Folder,
|
||||||
|
)
|
||||||
from .store import BackupStore
|
from .store import BackupStore
|
||||||
from .util import (
|
from .util import (
|
||||||
AsyncIteratorReader,
|
AsyncIteratorReader,
|
||||||
@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC):
|
|||||||
"""Get restore events after core restart."""
|
"""Get restore events after core restart."""
|
||||||
|
|
||||||
|
|
||||||
class BackupReaderWriterError(BackupError):
|
|
||||||
"""Backup reader/writer error."""
|
|
||||||
|
|
||||||
error_code = "backup_reader_writer_error"
|
|
||||||
|
|
||||||
|
|
||||||
class IncorrectPasswordError(BackupReaderWriterError):
|
class IncorrectPasswordError(BackupReaderWriterError):
|
||||||
"""Raised when the password is incorrect."""
|
"""Raised when the password is incorrect."""
|
||||||
|
|
||||||
|
@ -41,12 +41,6 @@ class BaseBackup:
|
|||||||
homeassistant_version: str | None # None if homeassistant_included is False
|
homeassistant_version: str | None # None if homeassistant_included is False
|
||||||
name: str
|
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)
|
@dataclass(frozen=True, kw_only=True)
|
||||||
class AgentBackup(BaseBackup):
|
class AgentBackup(BaseBackup):
|
||||||
@ -83,7 +77,25 @@ class BackupError(HomeAssistantError):
|
|||||||
error_code = "unknown"
|
error_code = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class BackupAgentError(BackupError):
|
||||||
|
"""Base class for backup agent errors."""
|
||||||
|
|
||||||
|
error_code = "backup_agent_error"
|
||||||
|
|
||||||
|
|
||||||
class BackupManagerError(BackupError):
|
class BackupManagerError(BackupError):
|
||||||
"""Backup manager error."""
|
"""Backup manager error."""
|
||||||
|
|
||||||
error_code = "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"
|
||||||
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||||
|
from concurrent.futures import CancelledError, Future
|
||||||
import copy
|
import copy
|
||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@ -12,6 +13,7 @@ import os
|
|||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
from queue import SimpleQueue
|
from queue import SimpleQueue
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import threading
|
||||||
from typing import IO, Any, Self, cast
|
from typing import IO, Any, Self, cast
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@ -22,7 +24,6 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
from homeassistant.util.thread import ThreadWithException
|
|
||||||
|
|
||||||
from .const import BUF_SIZE, LOGGER
|
from .const import BUF_SIZE, LOGGER
|
||||||
from .models import AddonInfo, AgentBackup, Folder
|
from .models import AddonInfo, AgentBackup, Folder
|
||||||
@ -167,23 +168,38 @@ class AsyncIteratorReader:
|
|||||||
|
|
||||||
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
|
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
|
||||||
"""Initialize the wrapper."""
|
"""Initialize the wrapper."""
|
||||||
|
self._aborted = False
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._stream = stream
|
self._stream = stream
|
||||||
self._buffer: bytes | None = None
|
self._buffer: bytes | None = None
|
||||||
|
self._next_future: Future[bytes | None] | None = None
|
||||||
self._pos: int = 0
|
self._pos: int = 0
|
||||||
|
|
||||||
async def _next(self) -> bytes | None:
|
async def _next(self) -> bytes | None:
|
||||||
"""Get the next chunk from the iterator."""
|
"""Get the next chunk from the iterator."""
|
||||||
return await anext(self._stream, None)
|
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:
|
def read(self, n: int = -1, /) -> bytes:
|
||||||
"""Read data from the iterator."""
|
"""Read data from the iterator."""
|
||||||
result = bytearray()
|
result = bytearray()
|
||||||
while n < 0 or len(result) < n:
|
while n < 0 or len(result) < n:
|
||||||
if not self._buffer:
|
if not self._buffer:
|
||||||
self._buffer = asyncio.run_coroutine_threadsafe(
|
self._next_future = asyncio.run_coroutine_threadsafe(
|
||||||
self._next(), self._hass.loop
|
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
|
self._pos = 0
|
||||||
if not self._buffer:
|
if not self._buffer:
|
||||||
# The stream is exhausted
|
# The stream is exhausted
|
||||||
@ -205,9 +221,11 @@ class AsyncIteratorWriter:
|
|||||||
|
|
||||||
def __init__(self, hass: HomeAssistant) -> None:
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize the wrapper."""
|
"""Initialize the wrapper."""
|
||||||
|
self._aborted = False
|
||||||
self._hass = hass
|
self._hass = hass
|
||||||
self._pos: int = 0
|
self._pos: int = 0
|
||||||
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
|
||||||
|
self._write_future: Future[bytes | None] | None = None
|
||||||
|
|
||||||
def __aiter__(self) -> Self:
|
def __aiter__(self) -> Self:
|
||||||
"""Return the iterator."""
|
"""Return the iterator."""
|
||||||
@ -219,13 +237,28 @@ class AsyncIteratorWriter:
|
|||||||
return data
|
return data
|
||||||
raise StopAsyncIteration
|
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:
|
def tell(self) -> int:
|
||||||
"""Return the current position in the iterator."""
|
"""Return the current position in the iterator."""
|
||||||
return self._pos
|
return self._pos
|
||||||
|
|
||||||
def write(self, s: bytes, /) -> int:
|
def write(self, s: bytes, /) -> int:
|
||||||
"""Write data to the iterator."""
|
"""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)
|
self._pos += len(s)
|
||||||
return len(s)
|
return len(s)
|
||||||
|
|
||||||
@ -415,7 +448,9 @@ def _encrypt_backup(
|
|||||||
class _CipherWorkerStatus:
|
class _CipherWorkerStatus:
|
||||||
done: asyncio.Event
|
done: asyncio.Event
|
||||||
error: Exception | None = None
|
error: Exception | None = None
|
||||||
thread: ThreadWithException
|
reader: AsyncIteratorReader
|
||||||
|
thread: threading.Thread
|
||||||
|
writer: AsyncIteratorWriter
|
||||||
|
|
||||||
|
|
||||||
class _CipherBackupStreamer:
|
class _CipherBackupStreamer:
|
||||||
@ -468,11 +503,13 @@ class _CipherBackupStreamer:
|
|||||||
stream = await self._open_stream()
|
stream = await self._open_stream()
|
||||||
reader = AsyncIteratorReader(self._hass, stream)
|
reader = AsyncIteratorReader(self._hass, stream)
|
||||||
writer = AsyncIteratorWriter(self._hass)
|
writer = AsyncIteratorWriter(self._hass)
|
||||||
worker = ThreadWithException(
|
worker = threading.Thread(
|
||||||
target=self._cipher_func,
|
target=self._cipher_func,
|
||||||
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
|
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)
|
self._workers.append(worker_status)
|
||||||
worker.start()
|
worker.start()
|
||||||
return writer
|
return writer
|
||||||
@ -480,9 +517,8 @@ class _CipherBackupStreamer:
|
|||||||
async def wait(self) -> None:
|
async def wait(self) -> None:
|
||||||
"""Wait for the worker threads to finish."""
|
"""Wait for the worker threads to finish."""
|
||||||
for worker in self._workers:
|
for worker in self._workers:
|
||||||
if not worker.thread.is_alive():
|
worker.reader.abort()
|
||||||
continue
|
worker.writer.abort()
|
||||||
worker.thread.raise_exc(AbortCipher)
|
|
||||||
await asyncio.gather(*(worker.done.wait() for worker in self._workers))
|
await asyncio.gather(*(worker.done.wait() for worker in self._workers))
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ from .manager import (
|
|||||||
IncorrectPasswordError,
|
IncorrectPasswordError,
|
||||||
ManagerStateEvent,
|
ManagerStateEvent,
|
||||||
)
|
)
|
||||||
from .models import Folder
|
from .models import BackupNotFound, Folder
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
@ -57,7 +57,7 @@ async def handle_info(
|
|||||||
"agent_errors": {
|
"agent_errors": {
|
||||||
agent_id: str(err) for agent_id, err in agent_errors.items()
|
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_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
|
||||||
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
|
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
|
||||||
"last_non_idle_event": manager.last_non_idle_event,
|
"last_non_idle_event": manager.last_non_idle_event,
|
||||||
@ -91,7 +91,7 @@ async def handle_details(
|
|||||||
"agent_errors": {
|
"agent_errors": {
|
||||||
agent_id: str(err) for agent_id, err in agent_errors.items()
|
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_folders=msg.get("restore_folders"),
|
||||||
restore_homeassistant=msg["restore_homeassistant"],
|
restore_homeassistant=msg["restore_homeassistant"],
|
||||||
)
|
)
|
||||||
|
except BackupNotFound:
|
||||||
|
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
|
||||||
except IncorrectPasswordError:
|
except IncorrectPasswordError:
|
||||||
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
|
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
|
||||||
else:
|
else:
|
||||||
@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download(
|
|||||||
agent_id=msg["agent_id"],
|
agent_id=msg["agent_id"],
|
||||||
password=msg.get("password"),
|
password=msg.get("password"),
|
||||||
)
|
)
|
||||||
|
except BackupNotFound:
|
||||||
|
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
|
||||||
except IncorrectPasswordError:
|
except IncorrectPasswordError:
|
||||||
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
|
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
|
||||||
except DecryptOnDowloadNotSupported:
|
except DecryptOnDowloadNotSupported:
|
||||||
|
@ -19,6 +19,8 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import BangOlufsenEntity
|
from .entity import BangOlufsenEntity
|
||||||
|
|
||||||
|
PARALLEL_UPDATES = 0
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
"services": {
|
"services": {
|
||||||
"join": {
|
"join": {
|
||||||
"name": "Join",
|
"name": "Join",
|
||||||
"description": "Group player together.",
|
"description": "Groups players together under a single master speaker.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"master": {
|
"master": {
|
||||||
"name": "Master",
|
"name": "Master",
|
||||||
@ -36,23 +36,23 @@
|
|||||||
},
|
},
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"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": {
|
"unjoin": {
|
||||||
"name": "Unjoin",
|
"name": "Unjoin",
|
||||||
"description": "Unjoin the player from a group.",
|
"description": "Separates a player from a group.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"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": {
|
"set_sleep_timer": {
|
||||||
"name": "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": {
|
"fields": {
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"name": "Entity",
|
||||||
@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
"clear_sleep_timer": {
|
"clear_sleep_timer": {
|
||||||
"name": "Clear sleep timer",
|
"name": "Clear sleep timer",
|
||||||
"description": "Clear a Bluesound timer.",
|
"description": "Clears a Bluesound timer.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"entity_id": {
|
"entity_id": {
|
||||||
"name": "Entity",
|
"name": "Entity",
|
||||||
|
@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import platform
|
import platform
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from bleak_retry_connector import BleakSlotManager
|
from bleak_retry_connector import BleakSlotManager
|
||||||
from bluetooth_adapters import (
|
from bluetooth_adapters import (
|
||||||
@ -80,6 +80,7 @@ from .const import (
|
|||||||
CONF_DETAILS,
|
CONF_DETAILS,
|
||||||
CONF_PASSIVE,
|
CONF_PASSIVE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -297,7 +298,11 @@ async def async_discover_adapters(
|
|||||||
|
|
||||||
|
|
||||||
async def async_update_device(
|
async def async_update_device(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
|
hass: HomeAssistant,
|
||||||
|
entry: ConfigEntry,
|
||||||
|
adapter: str,
|
||||||
|
details: AdapterDetails,
|
||||||
|
via_device_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update device registry entry.
|
"""Update device registry entry.
|
||||||
|
|
||||||
@ -306,7 +311,8 @@ async def async_update_device(
|
|||||||
update the device with the new location so they can
|
update the device with the new location so they can
|
||||||
figure out where the adapter is.
|
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,
|
config_entry_id=entry.entry_id,
|
||||||
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
|
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
|
||||||
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
|
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
|
||||||
@ -315,6 +321,11 @@ async def async_update_device(
|
|||||||
sw_version=details.get(ADAPTER_SW_VERSION),
|
sw_version=details.get(ADAPTER_SW_VERSION),
|
||||||
hw_version=details.get(ADAPTER_HW_VERSION),
|
hw_version=details.get(ADAPTER_HW_VERSION),
|
||||||
)
|
)
|
||||||
|
if via_device_id and (via_device_entry := device_registry.async_get(via_device_id)):
|
||||||
|
kwargs: dict[str, Any] = {"via_device_id": via_device_id}
|
||||||
|
if not device_entry.area_id and via_device_entry.area_id:
|
||||||
|
kwargs["area_id"] = via_device_entry.area_id
|
||||||
|
device_registry.async_update_device(device_entry.id, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
@ -349,6 +360,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
entry,
|
entry,
|
||||||
source_entry.title,
|
source_entry.title,
|
||||||
details,
|
details,
|
||||||
|
entry.data.get(CONF_SOURCE_DEVICE_ID),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
manager = _get_manager(hass)
|
manager = _get_manager(hass)
|
||||||
|
@ -181,10 +181,16 @@ def async_register_scanner(
|
|||||||
source_domain: str | None = None,
|
source_domain: str | None = None,
|
||||||
source_model: str | None = None,
|
source_model: str | None = None,
|
||||||
source_config_entry_id: str | None = None,
|
source_config_entry_id: str | None = None,
|
||||||
|
source_device_id: str | None = None,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Register a BleakScanner."""
|
"""Register a BleakScanner."""
|
||||||
return _get_manager(hass).async_register_hass_scanner(
|
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_PASSIVE,
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -139,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=adapter_title(adapter, details), data={}
|
title=adapter_title(adapter, details), data={}
|
||||||
)
|
)
|
||||||
|
|
||||||
configured_addresses = self._async_current_ids()
|
configured_addresses = self._async_current_ids(include_ignore=False)
|
||||||
bluetooth_adapters = get_adapters()
|
bluetooth_adapters = get_adapters()
|
||||||
await bluetooth_adapters.refresh()
|
await bluetooth_adapters.refresh()
|
||||||
self._adapters = bluetooth_adapters.adapters
|
self._adapters = bluetooth_adapters.adapters
|
||||||
@ -154,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
|
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
|
||||||
]
|
]
|
||||||
if not unconfigured_adapters:
|
if not unconfigured_adapters:
|
||||||
ignored_adapters = len(
|
|
||||||
self._async_current_entries(include_ignore=True)
|
|
||||||
) - len(self._async_current_entries(include_ignore=False))
|
|
||||||
return self.async_abort(
|
return self.async_abort(
|
||||||
reason="no_adapters",
|
reason="no_adapters",
|
||||||
description_placeholders={"ignored_adapters": str(ignored_adapters)},
|
|
||||||
)
|
)
|
||||||
if len(unconfigured_adapters) == 1:
|
if len(unconfigured_adapters) == 1:
|
||||||
self._adapter = list(self._adapters)[0]
|
self._adapter = list(self._adapters)[0]
|
||||||
@ -194,6 +191,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
|
CONF_SOURCE_MODEL: user_input[CONF_SOURCE_MODEL],
|
||||||
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
|
CONF_SOURCE_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
|
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)
|
self._abort_if_unique_id_configured(updates=data)
|
||||||
manager = get_manager()
|
manager = get_manager()
|
||||||
|
@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
|
|||||||
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
CONF_SOURCE_DOMAIN: Final = "source_domain"
|
||||||
CONF_SOURCE_MODEL: Final = "source_model"
|
CONF_SOURCE_MODEL: Final = "source_model"
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
|
||||||
|
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
|
||||||
|
|
||||||
SOURCE_LOCAL: Final = "local"
|
SOURCE_LOCAL: Final = "local"
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
|||||||
from .const import (
|
from .const import (
|
||||||
CONF_SOURCE,
|
CONF_SOURCE,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID,
|
CONF_SOURCE_CONFIG_ENTRY_ID,
|
||||||
|
CONF_SOURCE_DEVICE_ID,
|
||||||
CONF_SOURCE_DOMAIN,
|
CONF_SOURCE_DOMAIN,
|
||||||
CONF_SOURCE_MODEL,
|
CONF_SOURCE_MODEL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
source_domain: str | None = None,
|
source_domain: str | None = None,
|
||||||
source_model: str | None = None,
|
source_model: str | None = None,
|
||||||
source_config_entry_id: str | None = None,
|
source_config_entry_id: str | None = None,
|
||||||
|
source_device_id: str | None = None,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Register a scanner."""
|
"""Register a scanner."""
|
||||||
cancel = self.async_register_scanner(scanner, connection_slots)
|
cancel = self.async_register_scanner(scanner, connection_slots)
|
||||||
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
isinstance(scanner, BaseHaRemoteScanner)
|
isinstance(scanner, BaseHaRemoteScanner)
|
||||||
and source_domain
|
and source_domain
|
||||||
and source_config_entry_id
|
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.async_create_task(
|
||||||
self.hass.config_entries.flow.async_init(
|
self.hass.config_entries.flow.async_init(
|
||||||
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
|
|||||||
CONF_SOURCE_DOMAIN: source_domain,
|
CONF_SOURCE_DOMAIN: source_domain,
|
||||||
CONF_SOURCE_MODEL: source_model,
|
CONF_SOURCE_MODEL: source_model,
|
||||||
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
|
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
|
||||||
|
CONF_SOURCE_DEVICE_ID: source_device_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -16,11 +16,11 @@
|
|||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"bleak==0.22.3",
|
"bleak==0.22.3",
|
||||||
"bleak-retry-connector==3.8.0",
|
"bleak-retry-connector==3.8.1",
|
||||||
"bluetooth-adapters==0.21.1",
|
"bluetooth-adapters==0.21.4",
|
||||||
"bluetooth-auto-recovery==1.4.2",
|
"bluetooth-auto-recovery==1.4.2",
|
||||||
"bluetooth-data-tools==1.22.0",
|
"bluetooth-data-tools==1.23.4",
|
||||||
"dbus-fast==2.30.2",
|
"dbus-fast==2.32.0",
|
||||||
"habluetooth==3.20.1"
|
"habluetooth==3.21.1"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
},
|
},
|
||||||
"abort": {
|
"abort": {
|
||||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
|
||||||
"no_adapters": "No unconfigured Bluetooth adapters found. There are {ignored_adapters} ignored adapters."
|
"no_adapters": "No unconfigured Bluetooth adapters found."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"options": {
|
"options": {
|
||||||
|
@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
return self._async_get_or_create_entry()
|
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):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
"documentation": "https://www.home-assistant.io/integrations/bthome",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["bthome-ble==3.12.3"]
|
"requirements": ["bthome-ble==3.12.4"]
|
||||||
}
|
}
|
||||||
|
@ -67,6 +67,11 @@ SENSOR_DESCRIPTIONS = {
|
|||||||
state_class=SensorStateClass.MEASUREMENT,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
),
|
),
|
||||||
|
# Channel (-)
|
||||||
|
(BTHomeExtendedSensorDeviceClass.CHANNEL, None): SensorEntityDescription(
|
||||||
|
key=str(BTHomeExtendedSensorDeviceClass.CHANNEL),
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
),
|
||||||
# Conductivity (µS/cm)
|
# Conductivity (µS/cm)
|
||||||
(
|
(
|
||||||
BTHomeSensorDeviceClass.CONDUCTIVITY,
|
BTHomeSensorDeviceClass.CONDUCTIVITY,
|
||||||
|
@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
|
|||||||
from homeassistant.components.homeassistant import exposed_entities
|
from homeassistant.components.homeassistant import exposed_entities
|
||||||
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
|
||||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
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.const import CLOUD_NEVER_EXPOSED_ENTITIES
|
||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
|
|||||||
hass.http.register_view(CloudRegisterView)
|
hass.http.register_view(CloudRegisterView)
|
||||||
hass.http.register_view(CloudResendConfirmView)
|
hass.http.register_view(CloudResendConfirmView)
|
||||||
hass.http.register_view(CloudForgotPasswordView)
|
hass.http.register_view(CloudForgotPasswordView)
|
||||||
|
hass.http.register_view(DownloadSupportPackageView)
|
||||||
|
|
||||||
_CLOUD_ERRORS.update(
|
_CLOUD_ERRORS.update(
|
||||||
{
|
{
|
||||||
@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
|
|||||||
return self.json_message("ok")
|
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.require_admin
|
||||||
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
|
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
|
||||||
@websocket_api.async_response
|
@websocket_api.async_response
|
||||||
|
@ -13,6 +13,6 @@
|
|||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["hass_nabucasa"],
|
"loggers": ["hass_nabucasa"],
|
||||||
"requirements": ["hass-nabucasa==0.88.1"],
|
"requirements": ["hass-nabucasa==0.89.0"],
|
||||||
"single_config_entry": true
|
"single_config_entry": true
|
||||||
}
|
}
|
||||||
|
@ -38,6 +38,156 @@ ATTR_GENDER = "gender"
|
|||||||
DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"}
|
DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"}
|
||||||
SUPPORT_LANGUAGES = list(TTS_VOICES)
|
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__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -186,12 +336,13 @@ class CloudTTSEntity(TextToSpeechEntity):
|
|||||||
"""Load TTS from Home Assistant Cloud."""
|
"""Load TTS from Home Assistant Cloud."""
|
||||||
gender: Gender | str | None = options.get(ATTR_GENDER)
|
gender: Gender | str | None = options.get(ATTR_GENDER)
|
||||||
gender = handle_deprecated_gender(self.hass, gender)
|
gender = handle_deprecated_gender(self.hass, gender)
|
||||||
original_voice: str | None = options.get(ATTR_VOICE)
|
original_voice: str = options.get(
|
||||||
if original_voice is None and language == self._language:
|
ATTR_VOICE,
|
||||||
original_voice = self._voice
|
self._voice if language == self._language else DEFAULT_VOICES[language],
|
||||||
|
)
|
||||||
voice = handle_deprecated_voice(self.hass, original_voice)
|
voice = handle_deprecated_voice(self.hass, original_voice)
|
||||||
if voice not in TTS_VOICES[language]:
|
if voice not in TTS_VOICES[language]:
|
||||||
default_voice = TTS_VOICES[language][0]
|
default_voice = DEFAULT_VOICES[language]
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Unsupported voice %s detected, falling back to default %s for %s",
|
"Unsupported voice %s detected, falling back to default %s for %s",
|
||||||
voice,
|
voice,
|
||||||
@ -266,12 +417,13 @@ class CloudProvider(Provider):
|
|||||||
assert self.hass is not None
|
assert self.hass is not None
|
||||||
gender: Gender | str | None = options.get(ATTR_GENDER)
|
gender: Gender | str | None = options.get(ATTR_GENDER)
|
||||||
gender = handle_deprecated_gender(self.hass, gender)
|
gender = handle_deprecated_gender(self.hass, gender)
|
||||||
original_voice: str | None = options.get(ATTR_VOICE)
|
original_voice: str = options.get(
|
||||||
if original_voice is None and language == self._language:
|
ATTR_VOICE,
|
||||||
original_voice = self._voice
|
self._voice if language == self._language else DEFAULT_VOICES[language],
|
||||||
|
)
|
||||||
voice = handle_deprecated_voice(self.hass, original_voice)
|
voice = handle_deprecated_voice(self.hass, original_voice)
|
||||||
if voice not in TTS_VOICES[language]:
|
if voice not in TTS_VOICES[language]:
|
||||||
default_voice = TTS_VOICES[language][0]
|
default_voice = DEFAULT_VOICES[language]
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Unsupported voice %s detected, falling back to default %s for %s",
|
"Unsupported voice %s detected, falling back to default %s for %s",
|
||||||
voice,
|
voice,
|
||||||
|
@ -302,7 +302,8 @@ def config_entries_progress(
|
|||||||
[
|
[
|
||||||
flw
|
flw
|
||||||
for flw in hass.config_entries.flow.async_progress()
|
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,
|
async_get_agent,
|
||||||
get_agent_manager,
|
get_agent_manager,
|
||||||
)
|
)
|
||||||
|
from .chat_log import (
|
||||||
|
AssistantContent,
|
||||||
|
ChatLog,
|
||||||
|
Content,
|
||||||
|
ConverseError,
|
||||||
|
SystemContent,
|
||||||
|
ToolResultContent,
|
||||||
|
UserContent,
|
||||||
|
async_get_chat_log,
|
||||||
|
)
|
||||||
from .const import (
|
from .const import (
|
||||||
ATTR_AGENT_ID,
|
ATTR_AGENT_ID,
|
||||||
ATTR_CONVERSATION_ID,
|
ATTR_CONVERSATION_ID,
|
||||||
@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
|
|||||||
from .entity import ConversationEntity
|
from .entity import ConversationEntity
|
||||||
from .http import async_setup as async_setup_conversation_http
|
from .http import async_setup as async_setup_conversation_http
|
||||||
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
|
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
|
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"DOMAIN",
|
"DOMAIN",
|
||||||
"HOME_ASSISTANT_AGENT",
|
"HOME_ASSISTANT_AGENT",
|
||||||
"OLD_HOME_ASSISTANT_AGENT",
|
"OLD_HOME_ASSISTANT_AGENT",
|
||||||
"ChatSession",
|
"AssistantContent",
|
||||||
|
"ChatLog",
|
||||||
"Content",
|
"Content",
|
||||||
"ConversationEntity",
|
"ConversationEntity",
|
||||||
"ConversationEntityFeature",
|
"ConversationEntityFeature",
|
||||||
@ -69,11 +73,13 @@ __all__ = [
|
|||||||
"ConversationResult",
|
"ConversationResult",
|
||||||
"ConversationTraceEventType",
|
"ConversationTraceEventType",
|
||||||
"ConverseError",
|
"ConverseError",
|
||||||
"NativeContent",
|
"SystemContent",
|
||||||
|
"ToolResultContent",
|
||||||
|
"UserContent",
|
||||||
"async_conversation_trace_append",
|
"async_conversation_trace_append",
|
||||||
"async_converse",
|
"async_converse",
|
||||||
"async_get_agent_info",
|
"async_get_agent_info",
|
||||||
"async_get_chat_session",
|
"async_get_chat_log",
|
||||||
"async_set_agent",
|
"async_set_agent",
|
||||||
"async_setup",
|
"async_setup",
|
||||||
"async_unset_agent",
|
"async_unset_agent",
|
||||||
|
299
homeassistant/components/conversation/chat_log.py
Normal file
299
homeassistant/components/conversation/chat_log.py
Normal file
@ -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.const import EVENT_STATE_CHANGED, MATCH_ALL
|
||||||
from homeassistant.helpers import (
|
from homeassistant.helpers import (
|
||||||
area_registry as ar,
|
area_registry as ar,
|
||||||
|
chat_session,
|
||||||
device_registry as dr,
|
device_registry as dr,
|
||||||
entity_registry as er,
|
entity_registry as er,
|
||||||
floor_registry as fr,
|
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.helpers.event import async_track_state_added_domain
|
||||||
from homeassistant.util.json import JsonObjectType, json_loads_object
|
from homeassistant.util.json import JsonObjectType, json_loads_object
|
||||||
|
|
||||||
|
from .chat_log import AssistantContent, async_get_chat_log
|
||||||
from .const import (
|
from .const import (
|
||||||
DATA_DEFAULT_ENTITY,
|
DATA_DEFAULT_ENTITY,
|
||||||
DEFAULT_EXPOSED_ATTRIBUTES,
|
DEFAULT_EXPOSED_ATTRIBUTES,
|
||||||
@ -62,7 +64,6 @@ from .const import (
|
|||||||
)
|
)
|
||||||
from .entity import ConversationEntity
|
from .entity import ConversationEntity
|
||||||
from .models import ConversationInput, ConversationResult
|
from .models import ConversationInput, ConversationResult
|
||||||
from .session import Content, async_get_chat_session
|
|
||||||
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
from .trace import ConversationTraceEventType, async_conversation_trace_append
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
|
|||||||
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
|
||||||
"""Process a sentence."""
|
"""Process a sentence."""
|
||||||
response: intent.IntentResponse | None = None
|
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
|
# Check if a trigger matched
|
||||||
if trigger_result := await self.async_recognize_sentence_trigger(
|
if trigger_result := await self.async_recognize_sentence_trigger(
|
||||||
user_input
|
user_input
|
||||||
@ -373,16 +379,15 @@ class DefaultAgent(ConversationEntity):
|
|||||||
)
|
)
|
||||||
|
|
||||||
speech: str = response.speech.get("plain", {}).get("speech", "")
|
speech: str = response.speech.get("plain", {}).get("speech", "")
|
||||||
chat_session.async_add_message(
|
chat_log.async_add_assistant_content_without_tools(
|
||||||
Content(
|
AssistantContent(
|
||||||
role="assistant",
|
agent_id=user_input.agent_id, # type: ignore[arg-type]
|
||||||
agent_id=user_input.agent_id,
|
|
||||||
content=speech,
|
content=speech,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return ConversationResult(
|
return ConversationResult(
|
||||||
response=response, conversation_id=chat_session.conversation_id
|
response=response, conversation_id=session.conversation_id
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_process_intent_result(
|
async def _async_process_intent_result(
|
||||||
|
@ -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,8 +14,8 @@
|
|||||||
],
|
],
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aiodhcpwatcher==1.0.2",
|
"aiodhcpwatcher==1.1.0",
|
||||||
"aiodiscover==2.1.0",
|
"aiodiscover==2.2.2",
|
||||||
"cached-ipaddress==0.8.0"
|
"cached-ipaddress==0.8.0"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
Data is fetched from DWD:
|
Data is fetched from DWD:
|
||||||
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
|
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)
|
Unwetterwarnungen (Stufe 3)
|
||||||
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
|
||||||
Wetterwarnungen (Stufe 1)
|
Wetterwarnungen (Stufe 1)
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/econet",
|
"documentation": "https://www.home-assistant.io/integrations/econet",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["paho_mqtt", "pyeconet"],
|
"loggers": ["paho_mqtt", "pyeconet"],
|
||||||
"requirements": ["pyeconet==0.1.23"]
|
"requirements": ["pyeconet==0.1.26"]
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||||
"requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"]
|
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"]
|
||||||
}
|
}
|
||||||
|
@ -22,5 +22,5 @@
|
|||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"loggers": ["eq3btsmart"],
|
"loggers": ["eq3btsmart"],
|
||||||
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"]
|
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
|
||||||
}
|
}
|
||||||
|
@ -28,6 +28,7 @@ def async_connect_scanner(
|
|||||||
entry_data: RuntimeEntryData,
|
entry_data: RuntimeEntryData,
|
||||||
cli: APIClient,
|
cli: APIClient,
|
||||||
device_info: DeviceInfo,
|
device_info: DeviceInfo,
|
||||||
|
device_id: str,
|
||||||
) -> CALLBACK_TYPE:
|
) -> CALLBACK_TYPE:
|
||||||
"""Connect scanner."""
|
"""Connect scanner."""
|
||||||
client_data = connect_scanner(cli, device_info, entry_data.available)
|
client_data = connect_scanner(cli, device_info, entry_data.available)
|
||||||
@ -45,6 +46,7 @@ def async_connect_scanner(
|
|||||||
source_domain=DOMAIN,
|
source_domain=DOMAIN,
|
||||||
source_model=device_info.model,
|
source_model=device_info.model,
|
||||||
source_config_entry_id=entry_data.entry_id,
|
source_config_entry_id=entry_data.entry_id,
|
||||||
|
source_device_id=device_id,
|
||||||
),
|
),
|
||||||
scanner.async_setup(),
|
scanner.async_setup(),
|
||||||
],
|
],
|
||||||
|
@ -425,7 +425,9 @@ class ESPHomeManager:
|
|||||||
|
|
||||||
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
|
||||||
entry_data.disconnect_callbacks.add(
|
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:
|
else:
|
||||||
bluetooth.async_remove_scanner(hass, device_info.mac_address)
|
bluetooth.async_remove_scanner(hass, device_info.mac_address)
|
||||||
@ -571,7 +573,9 @@ def _async_setup_device_registry(
|
|||||||
|
|
||||||
configuration_url = None
|
configuration_url = None
|
||||||
if device_info.webserver_port > 0:
|
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 (
|
elif (
|
||||||
(dashboard := async_get_dashboard(hass))
|
(dashboard := async_get_dashboard(hass))
|
||||||
and dashboard.data
|
and dashboard.data
|
||||||
|
@ -18,7 +18,7 @@
|
|||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==29.0.0",
|
"aioesphomeapi==29.0.0",
|
||||||
"esphome-dashboard-api==1.2.3",
|
"esphome-dashboard-api==1.2.3",
|
||||||
"bleak-esphome==2.6.0"
|
"bleak-esphome==2.7.1"
|
||||||
],
|
],
|
||||||
"zeroconf": ["_esphomelib._tcp.local."]
|
"zeroconf": ["_esphomelib._tcp.local."]
|
||||||
}
|
}
|
||||||
|
@ -1,33 +1,27 @@
|
|||||||
"""The FAA Delays integration."""
|
"""The FAA Delays integration."""
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import CONF_ID, Platform
|
from homeassistant.const import CONF_ID, Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .coordinator import FAAConfigEntry, FAADataUpdateCoordinator
|
||||||
from .coordinator import FAADataUpdateCoordinator
|
|
||||||
|
|
||||||
PLATFORMS = [Platform.BINARY_SENSOR]
|
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."""
|
"""Set up FAA Delays from a config entry."""
|
||||||
code = entry.data[CONF_ID]
|
code = entry.data[CONF_ID]
|
||||||
|
|
||||||
coordinator = FAADataUpdateCoordinator(hass, code)
|
coordinator = FAADataUpdateCoordinator(hass, entry, code)
|
||||||
await coordinator.async_config_entry_first_refresh()
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
|
||||||
hass.data.setdefault(DOMAIN, {})
|
entry.runtime_data = coordinator
|
||||||
hass.data[DOMAIN][entry.entry_id] = coordinator
|
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
if unload_ok:
|
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
|
||||||
return unload_ok
|
|
||||||
|
@ -12,13 +12,12 @@ from homeassistant.components.binary_sensor import (
|
|||||||
BinarySensorEntity,
|
BinarySensorEntity,
|
||||||
BinarySensorEntityDescription,
|
BinarySensorEntityDescription,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from . import FAADataUpdateCoordinator
|
from . import FAAConfigEntry, FAADataUpdateCoordinator
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
|
||||||
@ -84,10 +83,10 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = (
|
|||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
|
hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up a FAA sensor based on a config entry."""
|
"""Set up a FAA sensor based on a config entry."""
|
||||||
coordinator = hass.data[DOMAIN][entry.entry_id]
|
coordinator = entry.runtime_data
|
||||||
|
|
||||||
entities = [
|
entities = [
|
||||||
FAABinarySensor(coordinator, entry.entry_id, description)
|
FAABinarySensor(coordinator, entry.entry_id, description)
|
||||||
|
@ -7,6 +7,7 @@ import logging
|
|||||||
from aiohttp import ClientConnectionError
|
from aiohttp import ClientConnectionError
|
||||||
from faadelays import Airport
|
from faadelays import Airport
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import aiohttp_client
|
from homeassistant.helpers import aiohttp_client
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
@ -15,14 +16,20 @@ from .const import DOMAIN
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
type FAAConfigEntry = ConfigEntry[FAADataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]):
|
class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]):
|
||||||
"""Class to manage fetching FAA API data from a single endpoint."""
|
"""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."""
|
"""Initialize the coordinator."""
|
||||||
super().__init__(
|
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.session = aiohttp_client.async_get_clientsession(hass)
|
||||||
self.data = Airport(code, self.session)
|
self.data = Airport(code, self.session)
|
||||||
|
@ -4,20 +4,20 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.start import async_at_started
|
from homeassistant.helpers.start import async_at_started
|
||||||
|
|
||||||
from .const import DOMAIN, PLATFORMS
|
from .const import PLATFORMS
|
||||||
from .coordinator import FastdotcomDataUpdateCoordinator
|
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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."""
|
"""Set up Fast.com from a config entry."""
|
||||||
coordinator = FastdotcomDataUpdateCoordinator(hass)
|
coordinator = FastdotcomDataUpdateCoordinator(hass, entry)
|
||||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
|
entry.runtime_data = coordinator
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(
|
await hass.config_entries.async_forward_entry_setups(
|
||||||
entry,
|
entry,
|
||||||
@ -36,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return True
|
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."""
|
"""Unload Fast.com config entry."""
|
||||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
|
||||||
return unload_ok
|
|
||||||
|
@ -6,20 +6,24 @@ from datetime import timedelta
|
|||||||
|
|
||||||
from fastdotcom import fast_com
|
from fastdotcom import fast_com
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER
|
from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER
|
||||||
|
|
||||||
|
type FastdotcomConfigEntry = ConfigEntry[FastdotcomDataUpdateCoordinator]
|
||||||
|
|
||||||
|
|
||||||
class FastdotcomDataUpdateCoordinator(DataUpdateCoordinator[float]):
|
class FastdotcomDataUpdateCoordinator(DataUpdateCoordinator[float]):
|
||||||
"""Class to manage fetching Fast.com data API."""
|
"""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."""
|
"""Initialize the coordinator for Fast.com."""
|
||||||
super().__init__(
|
super().__init__(
|
||||||
hass,
|
hass,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
|
config_entry=entry,
|
||||||
name=DOMAIN,
|
name=DOMAIN,
|
||||||
update_interval=timedelta(hours=DEFAULT_INTERVAL),
|
update_interval=timedelta(hours=DEFAULT_INTERVAL),
|
||||||
)
|
)
|
||||||
|
@ -4,21 +4,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .coordinator import FastdotcomConfigEntry
|
||||||
from .coordinator import FastdotcomDataUpdateCoordinator
|
|
||||||
|
|
||||||
|
|
||||||
async def async_get_config_entry_diagnostics(
|
async def async_get_config_entry_diagnostics(
|
||||||
hass: HomeAssistant, config_entry: ConfigEntry
|
hass: HomeAssistant, config_entry: FastdotcomConfigEntry
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return diagnostics for the config entry."""
|
"""Return diagnostics for the config entry."""
|
||||||
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][
|
return {"coordinator_data": config_entry.runtime_data.data}
|
||||||
config_entry.entry_id
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"coordinator_data": coordinator.data,
|
|
||||||
}
|
|
||||||
|
@ -7,7 +7,6 @@ from homeassistant.components.sensor import (
|
|||||||
SensorEntity,
|
SensorEntity,
|
||||||
SensorStateClass,
|
SensorStateClass,
|
||||||
)
|
)
|
||||||
from homeassistant.config_entries import ConfigEntry
|
|
||||||
from homeassistant.const import UnitOfDataRate
|
from homeassistant.const import UnitOfDataRate
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
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 homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
from .coordinator import FastdotcomDataUpdateCoordinator
|
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: ConfigEntry,
|
entry: FastdotcomConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Fast.com sensor."""
|
"""Set up the Fast.com sensor."""
|
||||||
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
|
async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)])
|
||||||
async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)])
|
|
||||||
|
|
||||||
|
|
||||||
class SpeedtestSensor(CoordinatorEntity[FastdotcomDataUpdateCoordinator], SensorEntity):
|
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 import config_validation as cv
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
from homeassistant.util import raise_if_invalid_filename
|
from homeassistant.util import raise_if_invalid_filename
|
||||||
|
from homeassistant.util.hass_dict import HassKey
|
||||||
from homeassistant.util.ulid import ulid_hex
|
from homeassistant.util.ulid import ulid_hex
|
||||||
|
|
||||||
DOMAIN = "file_upload"
|
DOMAIN = "file_upload"
|
||||||
|
_DATA: HassKey[FileUploadData] = HassKey(DOMAIN)
|
||||||
|
|
||||||
ONE_MEGABYTE = 1024 * 1024
|
ONE_MEGABYTE = 1024 * 1024
|
||||||
MAX_SIZE = 100 * ONE_MEGABYTE
|
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:
|
if DOMAIN not in hass.data:
|
||||||
raise ValueError("File does not exist")
|
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):
|
if not file_upload_data.has_file(file_id):
|
||||||
raise ValueError("File does not exist")
|
raise ValueError("File does not exist")
|
||||||
@ -149,10 +151,10 @@ class FileUploadView(HomeAssistantView):
|
|||||||
hass = request.app[KEY_HASS]
|
hass = request.app[KEY_HASS]
|
||||||
file_id = ulid_hex()
|
file_id = ulid_hex()
|
||||||
|
|
||||||
if DOMAIN not in hass.data:
|
if _DATA not in hass.data:
|
||||||
hass.data[DOMAIN] = await FileUploadData.create(hass)
|
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)
|
file_dir = file_upload_data.file_dir(file_id)
|
||||||
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
|
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
|
||||||
SimpleQueue()
|
SimpleQueue()
|
||||||
@ -206,7 +208,7 @@ class FileUploadView(HomeAssistantView):
|
|||||||
raise web.HTTPNotFound
|
raise web.HTTPNotFound
|
||||||
|
|
||||||
file_id = data["file_id"]
|
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:
|
if file_upload_data.files.pop(file_id, None) is None:
|
||||||
raise web.HTTPNotFound
|
raise web.HTTPNotFound
|
||||||
|
@ -21,5 +21,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20250131.0"]
|
"requirements": ["home-assistant-frontend==20250204.0"]
|
||||||
}
|
}
|
||||||
|
@ -244,7 +244,7 @@ class AFSAPIDevice(MediaPlayerEntity):
|
|||||||
"""Send volume up command."""
|
"""Send volume up command."""
|
||||||
volume = await self.fs_device.get_volume()
|
volume = await self.fs_device.get_volume()
|
||||||
volume = int(volume or 0) + 1
|
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:
|
async def async_volume_down(self) -> None:
|
||||||
"""Send volume down command."""
|
"""Send volume down command."""
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity):
|
|||||||
value, extra_state_attributes = self.entity_description.state_fn(value)
|
value, extra_state_attributes = self.entity_description.state_fn(value)
|
||||||
|
|
||||||
if self.entity_description.round_state_value:
|
if self.entity_description.round_state_value:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
assert isinstance(value, int)
|
||||||
value = round_storage(value)
|
value = round_storage(value)
|
||||||
|
|
||||||
self._attr_native_value = value
|
self._attr_native_value = value
|
||||||
|
@ -28,14 +28,14 @@
|
|||||||
"user": {
|
"user": {
|
||||||
"description": "Enter the settings to connect to the camera.",
|
"description": "Enter the settings to connect to the camera.",
|
||||||
"data": {
|
"data": {
|
||||||
"still_image_url": "Still Image URL (e.g. http://...)",
|
"still_image_url": "Still image URL (e.g. http://...)",
|
||||||
"stream_source": "Stream Source URL (e.g. rtsp://...)",
|
"stream_source": "Stream source URL (e.g. rtsp://...)",
|
||||||
"rtsp_transport": "RTSP transport protocol",
|
"rtsp_transport": "RTSP transport protocol",
|
||||||
"authentication": "Authentication",
|
"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%]",
|
"password": "[%key:common::config_flow::data::password%]",
|
||||||
"username": "[%key:common::config_flow::data::username%]",
|
"username": "[%key:common::config_flow::data::username%]",
|
||||||
"framerate": "Frame Rate (Hz)",
|
"framerate": "Frame rate (Hz)",
|
||||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import codecs
|
import codecs
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal, cast
|
||||||
|
|
||||||
from google.api_core.exceptions import GoogleAPIError
|
from google.api_core.exceptions import GoogleAPIError
|
||||||
import google.generativeai as genai
|
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.const import CONF_LLM_HASS_API, MATCH_ALL
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.exceptions import HomeAssistantError
|
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 homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _chat_message_convert(
|
def _create_google_tool_response_content(
|
||||||
message: conversation.Content | conversation.NativeContent[genai_types.ContentDict],
|
content: list[conversation.ToolResultContent],
|
||||||
) -> genai_types.ContentDict:
|
) -> protos.Content:
|
||||||
"""Convert any native chat message for this agent to the native format."""
|
"""Create a Google tool response content."""
|
||||||
if message.role == "native":
|
return protos.Content(
|
||||||
return message.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(
|
class GoogleGenerativeAIConversationEntity(
|
||||||
@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity(
|
|||||||
self, user_input: conversation.ConversationInput
|
self, user_input: conversation.ConversationInput
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
"""Process a sentence."""
|
"""Process a sentence."""
|
||||||
async with conversation.async_get_chat_session(
|
with (
|
||||||
self.hass, user_input
|
chat_session.async_get_chat_session(
|
||||||
) as session:
|
self.hass, user_input.conversation_id
|
||||||
return await self._async_handle_message(user_input, session)
|
) 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(
|
async def _async_handle_message(
|
||||||
self,
|
self,
|
||||||
user_input: conversation.ConversationInput,
|
user_input: conversation.ConversationInput,
|
||||||
session: conversation.ChatSession[genai_types.ContentDict],
|
chat_log: conversation.ChatLog,
|
||||||
) -> conversation.ConversationResult:
|
) -> conversation.ConversationResult:
|
||||||
"""Call the API."""
|
"""Call the API."""
|
||||||
|
|
||||||
@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity(
|
|||||||
options = self.entry.options
|
options = self.entry.options
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await session.async_update_llm_data(
|
await chat_log.async_update_llm_data(
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
user_input,
|
user_input,
|
||||||
options.get(CONF_LLM_HASS_API),
|
options.get(CONF_LLM_HASS_API),
|
||||||
@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity(
|
|||||||
return err.as_conversation_result()
|
return err.as_conversation_result()
|
||||||
|
|
||||||
tools: list[dict[str, Any]] | None = None
|
tools: list[dict[str, Any]] | None = None
|
||||||
if session.llm_api:
|
if chat_log.llm_api:
|
||||||
tools = [
|
tools = [
|
||||||
_format_tool(tool, session.llm_api.custom_serializer)
|
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||||
for tool in session.llm_api.tools
|
for tool in chat_log.llm_api.tools
|
||||||
]
|
]
|
||||||
|
|
||||||
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
|
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
|
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
|
||||||
)
|
)
|
||||||
|
|
||||||
prompt, *messages = [
|
prompt = chat_log.content[0].content # type: ignore[union-attr]
|
||||||
_chat_message_convert(message) for message in session.async_get_messages()
|
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 = genai.GenerativeModel(
|
||||||
model_name=model_name,
|
model_name=model_name,
|
||||||
generation_config={
|
generation_config={
|
||||||
@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
tools=tools or None,
|
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:
|
if not supports_system_instruction:
|
||||||
messages = [
|
messages = [
|
||||||
{"role": "user", "parts": prompt["parts"]},
|
{"role": "user", "parts": prompt},
|
||||||
{"role": "model", "parts": "Ok"},
|
{"role": "model", "parts": "Ok"},
|
||||||
*messages,
|
*messages,
|
||||||
]
|
]
|
||||||
@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity(
|
|||||||
content = " ".join(
|
content = " ".join(
|
||||||
[part.text.strip() for part in chat_response.parts if part.text]
|
[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 = [
|
tool_calls = []
|
||||||
part.function_call for part in chat_response.parts if part.function_call
|
for part in chat_response.parts:
|
||||||
]
|
if not part.function_call:
|
||||||
|
continue
|
||||||
if not function_calls or not session.llm_api:
|
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
|
||||||
break
|
|
||||||
|
|
||||||
tool_responses = []
|
|
||||||
for function_call in function_calls:
|
|
||||||
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
|
|
||||||
tool_name = tool_call["name"]
|
tool_name = tool_call["name"]
|
||||||
tool_args = _escape_decode(tool_call["args"])
|
tool_args = _escape_decode(tool_call["args"])
|
||||||
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
tool_calls.append(
|
||||||
function_response = await session.async_call_tool(tool_input)
|
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||||
tool_responses.append(
|
)
|
||||||
protos.Part(
|
|
||||||
function_response=protos.FunctionResponse(
|
chat_request = _create_google_tool_response_content(
|
||||||
name=tool_name, response=function_response
|
[
|
||||||
|
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 = intent.IntentResponse(language=user_input.language)
|
||||||
response.async_set_speech(
|
response.async_set_speech(
|
||||||
" ".join([part.text.strip() for part in chat_response.parts if part.text])
|
" ".join([part.text.strip() for part in chat_response.parts if part.text])
|
||||||
)
|
)
|
||||||
return conversation.ConversationResult(
|
return conversation.ConversationResult(
|
||||||
response=response, conversation_id=session.conversation_id
|
response=response, conversation_id=chat_log.conversation_id
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _async_entry_update_listener(
|
async def _async_entry_update_listener(
|
||||||
|
@ -131,5 +131,5 @@
|
|||||||
"dependencies": ["bluetooth_adapters"],
|
"dependencies": ["bluetooth_adapters"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["govee-ble==0.42.0"]
|
"requirements": ["govee-ble==0.42.1"]
|
||||||
}
|
}
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
"documentation": "https://www.home-assistant.io/integrations/habitica",
|
||||||
"iot_class": "cloud_polling",
|
"iot_class": "cloud_polling",
|
||||||
"loggers": ["habiticalib"],
|
"loggers": ["habiticalib"],
|
||||||
"requirements": ["habiticalib==0.3.3"]
|
"requirements": ["habiticalib==0.3.4"]
|
||||||
}
|
}
|
||||||
|
@ -27,15 +27,19 @@ from homeassistant.components.backup import (
|
|||||||
AgentBackup,
|
AgentBackup,
|
||||||
BackupAgent,
|
BackupAgent,
|
||||||
BackupManagerError,
|
BackupManagerError,
|
||||||
|
BackupNotFound,
|
||||||
BackupReaderWriter,
|
BackupReaderWriter,
|
||||||
BackupReaderWriterError,
|
BackupReaderWriterError,
|
||||||
CreateBackupEvent,
|
CreateBackupEvent,
|
||||||
|
CreateBackupStage,
|
||||||
|
CreateBackupState,
|
||||||
Folder,
|
Folder,
|
||||||
IdleEvent,
|
IdleEvent,
|
||||||
IncorrectPasswordError,
|
IncorrectPasswordError,
|
||||||
ManagerBackup,
|
ManagerBackup,
|
||||||
NewBackup,
|
NewBackup,
|
||||||
RestoreBackupEvent,
|
RestoreBackupEvent,
|
||||||
|
RestoreBackupStage,
|
||||||
RestoreBackupState,
|
RestoreBackupState,
|
||||||
WrittenBackup,
|
WrittenBackup,
|
||||||
async_get_manager as async_get_backup_manager,
|
async_get_manager as async_get_backup_manager,
|
||||||
@ -47,6 +51,7 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||||
from homeassistant.util import dt as dt_util
|
from homeassistant.util import dt as dt_util
|
||||||
|
from homeassistant.util.enum import try_parse_enum
|
||||||
|
|
||||||
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
|
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
|
||||||
from .handler import get_supervisor_client
|
from .handler import get_supervisor_client
|
||||||
@ -158,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent):
|
|||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> AsyncIterator[bytes]:
|
) -> AsyncIterator[bytes]:
|
||||||
"""Download a backup file."""
|
"""Download a backup file."""
|
||||||
return await self._client.backups.download_backup(
|
try:
|
||||||
backup_id,
|
return await self._client.backups.download_backup(
|
||||||
options=supervisor_backups.DownloadBackupOptions(location=self.location),
|
backup_id,
|
||||||
)
|
options=supervisor_backups.DownloadBackupOptions(
|
||||||
|
location=self.location
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except SupervisorNotFoundError as err:
|
||||||
|
raise BackupNotFound from err
|
||||||
|
|
||||||
async def async_upload_backup(
|
async def async_upload_backup(
|
||||||
self,
|
self,
|
||||||
@ -336,6 +346,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
self._async_wait_for_backup(
|
self._async_wait_for_backup(
|
||||||
backup,
|
backup,
|
||||||
locations,
|
locations,
|
||||||
|
on_progress=on_progress,
|
||||||
remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
|
remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
|
||||||
),
|
),
|
||||||
name="backup_manager_create_backup",
|
name="backup_manager_create_backup",
|
||||||
@ -349,18 +360,29 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
backup: supervisor_backups.NewBackup,
|
backup: supervisor_backups.NewBackup,
|
||||||
locations: list[str | None],
|
locations: list[str | None],
|
||||||
*,
|
*,
|
||||||
|
on_progress: Callable[[CreateBackupEvent], None],
|
||||||
remove_after_upload: bool,
|
remove_after_upload: bool,
|
||||||
) -> WrittenBackup:
|
) -> WrittenBackup:
|
||||||
"""Wait for a backup to complete."""
|
"""Wait for a backup to complete."""
|
||||||
backup_complete = asyncio.Event()
|
backup_complete = asyncio.Event()
|
||||||
backup_id: str | None = None
|
backup_id: str | None = None
|
||||||
|
create_errors: list[dict[str, str]] = []
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||||
"""Handle backup progress."""
|
"""Handle backup progress."""
|
||||||
nonlocal backup_id
|
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:
|
if data.get("done") is True:
|
||||||
backup_id = data.get("reference")
|
backup_id = data.get("reference")
|
||||||
|
create_errors.extend(data.get("errors", []))
|
||||||
backup_complete.set()
|
backup_complete.set()
|
||||||
|
|
||||||
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
|
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
|
||||||
@ -369,8 +391,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
await backup_complete.wait()
|
await backup_complete.wait()
|
||||||
finally:
|
finally:
|
||||||
unsub()
|
unsub()
|
||||||
if not backup_id:
|
if not backup_id or create_errors:
|
||||||
raise BackupReaderWriterError("Backup failed")
|
# 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]:
|
async def open_backup() -> AsyncIterator[bytes]:
|
||||||
try:
|
try:
|
||||||
@ -509,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
location=restore_location,
|
location=restore_location,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
except SupervisorNotFoundError as err:
|
||||||
|
raise BackupNotFound from err
|
||||||
except SupervisorBadRequestError as err:
|
except SupervisorBadRequestError as err:
|
||||||
# Supervisor currently does not transmit machine parsable error types
|
# Supervisor currently does not transmit machine parsable error types
|
||||||
message = err.args[0]
|
message = err.args[0]
|
||||||
@ -517,17 +544,30 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
raise HomeAssistantError(message) from err
|
raise HomeAssistantError(message) from err
|
||||||
|
|
||||||
restore_complete = asyncio.Event()
|
restore_complete = asyncio.Event()
|
||||||
|
restore_errors: list[dict[str, str]] = []
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||||
"""Handle backup restore 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:
|
if data.get("done") is True:
|
||||||
restore_complete.set()
|
restore_complete.set()
|
||||||
|
restore_errors.extend(data.get("errors", []))
|
||||||
|
|
||||||
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
|
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
|
||||||
try:
|
try:
|
||||||
await self._get_job_state(job.job_id, on_job_progress)
|
await self._get_job_state(job.job_id, on_job_progress)
|
||||||
await restore_complete.wait()
|
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:
|
finally:
|
||||||
unsub()
|
unsub()
|
||||||
|
|
||||||
@ -543,22 +583,45 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
|||||||
|
|
||||||
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
|
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
|
||||||
|
|
||||||
|
sent_event = False
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||||
"""Handle backup restore progress."""
|
"""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 data.get("done") is not True:
|
||||||
on_progress(
|
if stage or not sent_event:
|
||||||
RestoreBackupEvent(
|
sent_event = True
|
||||||
reason="", stage=None, state=RestoreBackupState.IN_PROGRESS
|
on_progress(
|
||||||
|
RestoreBackupEvent(
|
||||||
|
reason=None,
|
||||||
|
stage=stage,
|
||||||
|
state=RestoreBackupState.IN_PROGRESS,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
on_progress(
|
restore_errors = data.get("errors", [])
|
||||||
RestoreBackupEvent(
|
if restore_errors:
|
||||||
reason="", stage=None, state=RestoreBackupState.COMPLETED
|
_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())
|
on_progress(IdleEvent())
|
||||||
unsub()
|
unsub()
|
||||||
|
|
||||||
|
1
homeassistant/components/heicko/__init__.py
Normal file
1
homeassistant/components/heicko/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Virtual integration: Heicko."""
|
6
homeassistant/components/heicko/manifest.json
Normal file
6
homeassistant/components/heicko/manifest.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"domain": "heicko",
|
||||||
|
"name": "Heicko",
|
||||||
|
"integration_type": "virtual",
|
||||||
|
"supported_by": "motion_blinds"
|
||||||
|
}
|
@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
|
|||||||
async_delete_issue,
|
async_delete_issue,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
BSH_DOOR_STATE_CLOSED,
|
BSH_DOOR_STATE_CLOSED,
|
||||||
BSH_DOOR_STATE_LOCKED,
|
BSH_DOOR_STATE_LOCKED,
|
||||||
@ -113,24 +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(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect binary sensor."""
|
"""Set up the Home Connect binary sensor."""
|
||||||
|
setup_home_connect_entry(
|
||||||
entities: list[BinarySensorEntity] = []
|
entry,
|
||||||
for appliance in entry.runtime_data.data.values():
|
_get_entities_for_appliance,
|
||||||
entities.extend(
|
async_add_entities,
|
||||||
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))
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
||||||
|
99
homeassistant/components/home_connect/common.py
Normal file
99
homeassistant/components/home_connect/common.py
Normal file
@ -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,),
|
||||||
|
)
|
||||||
|
)
|
@ -3,7 +3,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@ -30,6 +30,7 @@ from propcache.api import cached_property
|
|||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
from homeassistant.helpers import device_registry as dr
|
||||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||||
@ -46,9 +47,9 @@ EVENT_STREAM_RECONNECT_DELAY = 30
|
|||||||
class HomeConnectApplianceData:
|
class HomeConnectApplianceData:
|
||||||
"""Class to hold Home Connect appliance data."""
|
"""Class to hold Home Connect appliance data."""
|
||||||
|
|
||||||
events: dict[EventKey, Event] = field(default_factory=dict)
|
events: dict[EventKey, Event]
|
||||||
info: HomeAppliance
|
info: HomeAppliance
|
||||||
programs: list[EnumerateProgram] = field(default_factory=list)
|
programs: list[EnumerateProgram]
|
||||||
settings: dict[SettingKey, GetSetting]
|
settings: dict[SettingKey, GetSetting]
|
||||||
status: dict[StatusKey, Status]
|
status: dict[StatusKey, Status]
|
||||||
|
|
||||||
@ -83,6 +84,10 @@ class HomeConnectCoordinator(
|
|||||||
name=config_entry.entry_id,
|
name=config_entry.entry_id,
|
||||||
)
|
)
|
||||||
self.client = client
|
self.client = client
|
||||||
|
self._special_listeners: dict[
|
||||||
|
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
|
||||||
|
] = {}
|
||||||
|
self.device_registry = dr.async_get(self.hass)
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
||||||
@ -107,6 +112,28 @@ class HomeConnectCoordinator(
|
|||||||
|
|
||||||
return remove_listener_and_invalidate_context_listeners
|
return remove_listener_and_invalidate_context_listeners
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_add_special_listener(
|
||||||
|
self,
|
||||||
|
update_callback: CALLBACK_TYPE,
|
||||||
|
context: tuple[EventKey, ...],
|
||||||
|
) -> Callable[[], None]:
|
||||||
|
"""Listen for special data updates.
|
||||||
|
|
||||||
|
These listeners will not be called on refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def remove_listener() -> None:
|
||||||
|
"""Remove update listener."""
|
||||||
|
self._special_listeners.pop(remove_listener)
|
||||||
|
if not self._special_listeners:
|
||||||
|
self._unschedule_refresh()
|
||||||
|
|
||||||
|
self._special_listeners[remove_listener] = (update_callback, context)
|
||||||
|
|
||||||
|
return remove_listener
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def start_event_listener(self) -> None:
|
def start_event_listener(self) -> None:
|
||||||
"""Start event listener."""
|
"""Start event listener."""
|
||||||
@ -161,18 +188,49 @@ class HomeConnectCoordinator(
|
|||||||
events[event.key] = event
|
events[event.key] = event
|
||||||
self._call_event_listener(event_message)
|
self._call_event_listener(event_message)
|
||||||
|
|
||||||
case EventType.CONNECTED:
|
case EventType.CONNECTED | EventType.PAIRED:
|
||||||
self.data[event_message_ha_id].info.connected = True
|
appliance_info = await self.client.get_specific_appliance(
|
||||||
self._call_all_event_listeners_for_appliance(
|
|
||||||
event_message_ha_id
|
event_message_ha_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
appliance_data = await self._get_appliance_data(
|
||||||
|
appliance_info, self.data.get(appliance_info.ha_id)
|
||||||
|
)
|
||||||
|
if event_message_ha_id in self.data:
|
||||||
|
self.data[event_message_ha_id].update(appliance_data)
|
||||||
|
else:
|
||||||
|
self.data[event_message_ha_id] = appliance_data
|
||||||
|
for listener, context in list(
|
||||||
|
self._special_listeners.values()
|
||||||
|
) + list(self._listeners.values()):
|
||||||
|
assert isinstance(context, tuple)
|
||||||
|
if (
|
||||||
|
EventKey.BSH_COMMON_APPLIANCE_DEPAIRED
|
||||||
|
not in context
|
||||||
|
):
|
||||||
|
listener()
|
||||||
|
|
||||||
case EventType.DISCONNECTED:
|
case EventType.DISCONNECTED:
|
||||||
self.data[event_message_ha_id].info.connected = False
|
self.data[event_message_ha_id].info.connected = False
|
||||||
self._call_all_event_listeners_for_appliance(
|
self._call_all_event_listeners_for_appliance(
|
||||||
event_message_ha_id
|
event_message_ha_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case EventType.DEPAIRED:
|
||||||
|
device = self.device_registry.async_get_device(
|
||||||
|
identifiers={(DOMAIN, event_message_ha_id)}
|
||||||
|
)
|
||||||
|
if device:
|
||||||
|
self.device_registry.async_update_device(
|
||||||
|
device_id=device.id,
|
||||||
|
remove_config_entry_id=self.config_entry.entry_id,
|
||||||
|
)
|
||||||
|
self.data.pop(event_message_ha_id, None)
|
||||||
|
for listener, context in self._special_listeners.values():
|
||||||
|
assert isinstance(context, tuple)
|
||||||
|
if EventKey.BSH_COMMON_APPLIANCE_DEPAIRED in context:
|
||||||
|
listener()
|
||||||
|
|
||||||
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"Non-breaking error (%s) while listening for events,"
|
"Non-breaking error (%s) while listening for events,"
|
||||||
@ -217,60 +275,101 @@ class HomeConnectCoordinator(
|
|||||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||||
) from error
|
) from error
|
||||||
|
|
||||||
appliances_data = self.data or {}
|
return {
|
||||||
for appliance in appliances.homeappliances:
|
appliance.ha_id: await self._get_appliance_data(
|
||||||
try:
|
appliance, self.data.get(appliance.ha_id) if self.data else None
|
||||||
settings = {
|
|
||||||
setting.key: setting
|
|
||||||
for setting in (
|
|
||||||
await self.client.get_settings(appliance.ha_id)
|
|
||||||
).settings
|
|
||||||
}
|
|
||||||
except HomeConnectError as error:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Error fetching settings for %s: %s",
|
|
||||||
appliance.ha_id,
|
|
||||||
error
|
|
||||||
if isinstance(error, HomeConnectApiError)
|
|
||||||
else type(error).__name__,
|
|
||||||
)
|
|
||||||
settings = {}
|
|
||||||
try:
|
|
||||||
status = {
|
|
||||||
status.key: status
|
|
||||||
for status in (await self.client.get_status(appliance.ha_id)).status
|
|
||||||
}
|
|
||||||
except HomeConnectError as error:
|
|
||||||
_LOGGER.debug(
|
|
||||||
"Error fetching status for %s: %s",
|
|
||||||
appliance.ha_id,
|
|
||||||
error
|
|
||||||
if isinstance(error, HomeConnectApiError)
|
|
||||||
else type(error).__name__,
|
|
||||||
)
|
|
||||||
status = {}
|
|
||||||
appliance_data = HomeConnectApplianceData(
|
|
||||||
info=appliance, settings=settings, status=status
|
|
||||||
)
|
)
|
||||||
if appliance.ha_id in appliances_data:
|
for appliance in appliances.homeappliances
|
||||||
appliances_data[appliance.ha_id].update(appliance_data)
|
}
|
||||||
appliance_data = appliances_data[appliance.ha_id]
|
|
||||||
|
async def _get_appliance_data(
|
||||||
|
self,
|
||||||
|
appliance: HomeAppliance,
|
||||||
|
appliance_data_to_update: HomeConnectApplianceData | None = None,
|
||||||
|
) -> HomeConnectApplianceData:
|
||||||
|
"""Get appliance data."""
|
||||||
|
self.device_registry.async_get_or_create(
|
||||||
|
config_entry_id=self.config_entry.entry_id,
|
||||||
|
identifiers={(DOMAIN, appliance.ha_id)},
|
||||||
|
manufacturer=appliance.brand,
|
||||||
|
name=appliance.name,
|
||||||
|
model=appliance.vib,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
settings = {
|
||||||
|
setting.key: setting
|
||||||
|
for setting in (
|
||||||
|
await self.client.get_settings(appliance.ha_id)
|
||||||
|
).settings
|
||||||
|
}
|
||||||
|
except HomeConnectError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error fetching settings for %s: %s",
|
||||||
|
appliance.ha_id,
|
||||||
|
error
|
||||||
|
if isinstance(error, HomeConnectApiError)
|
||||||
|
else type(error).__name__,
|
||||||
|
)
|
||||||
|
settings = {}
|
||||||
|
try:
|
||||||
|
status = {
|
||||||
|
status.key: status
|
||||||
|
for status in (await self.client.get_status(appliance.ha_id)).status
|
||||||
|
}
|
||||||
|
except HomeConnectError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error fetching status for %s: %s",
|
||||||
|
appliance.ha_id,
|
||||||
|
error
|
||||||
|
if isinstance(error, HomeConnectApiError)
|
||||||
|
else type(error).__name__,
|
||||||
|
)
|
||||||
|
status = {}
|
||||||
|
|
||||||
|
programs = []
|
||||||
|
events = {}
|
||||||
|
if appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||||
|
try:
|
||||||
|
all_programs = await self.client.get_all_programs(appliance.ha_id)
|
||||||
|
except HomeConnectError as error:
|
||||||
|
_LOGGER.debug(
|
||||||
|
"Error fetching programs for %s: %s",
|
||||||
|
appliance.ha_id,
|
||||||
|
error
|
||||||
|
if isinstance(error, HomeConnectApiError)
|
||||||
|
else type(error).__name__,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
appliances_data[appliance.ha_id] = appliance_data
|
programs.extend(all_programs.programs)
|
||||||
if (
|
for program, event_key in (
|
||||||
appliance.type in APPLIANCES_WITH_PROGRAMS
|
(
|
||||||
and not appliance_data.programs
|
all_programs.active,
|
||||||
):
|
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||||
try:
|
),
|
||||||
appliance_data.programs.extend(
|
(
|
||||||
(await self.client.get_all_programs(appliance.ha_id)).programs
|
all_programs.selected,
|
||||||
)
|
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||||
except HomeConnectError as error:
|
),
|
||||||
_LOGGER.debug(
|
):
|
||||||
"Error fetching programs for %s: %s",
|
if program and program.key:
|
||||||
appliance.ha_id,
|
events[event_key] = Event(
|
||||||
error
|
event_key,
|
||||||
if isinstance(error, HomeConnectApiError)
|
event_key.value,
|
||||||
else type(error).__name__,
|
0,
|
||||||
)
|
"",
|
||||||
return appliances_data
|
"",
|
||||||
|
program.key,
|
||||||
|
)
|
||||||
|
|
||||||
|
appliance_data = HomeConnectApplianceData(
|
||||||
|
events=events,
|
||||||
|
info=appliance,
|
||||||
|
programs=programs,
|
||||||
|
settings=settings,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
if appliance_data_to_update:
|
||||||
|
appliance_data_to_update.update(appliance_data)
|
||||||
|
appliance_data = appliance_data_to_update
|
||||||
|
|
||||||
|
return appliance_data
|
||||||
|
@ -35,9 +35,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
|||||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, appliance.info.ha_id)},
|
identifiers={(DOMAIN, appliance.info.ha_id)},
|
||||||
manufacturer=appliance.info.brand,
|
|
||||||
model=appliance.info.vib,
|
|
||||||
name=appliance.info.name,
|
|
||||||
)
|
)
|
||||||
self.update_native_value()
|
self.update_native_value()
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ from homeassistant.exceptions import HomeAssistantError
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import color as color_util
|
from homeassistant.util import color as color_util
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
@ -78,20 +79,28 @@ LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
HomeConnectLight(entry.runtime_data, appliance, description)
|
||||||
|
for description in LIGHTS
|
||||||
|
if description.key in appliance.settings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect light."""
|
"""Set up the Home Connect light."""
|
||||||
|
setup_home_connect_entry(
|
||||||
async_add_entities(
|
entry,
|
||||||
[
|
_get_entities_for_appliance,
|
||||||
HomeConnectLight(entry.runtime_data, appliance, description)
|
async_add_entities,
|
||||||
for description in LIGHTS
|
|
||||||
for appliance in entry.runtime_data.data.values()
|
|
||||||
if description.key in appliance.settings
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -6,6 +6,6 @@
|
|||||||
"dependencies": ["application_credentials"],
|
"dependencies": ["application_credentials"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||||
"iot_class": "cloud_push",
|
"iot_class": "cloud_push",
|
||||||
"loggers": ["homeconnect"],
|
"loggers": ["aiohomeconnect"],
|
||||||
"requirements": ["aiohomeconnect==0.12.1"]
|
"requirements": ["aiohomeconnect==0.12.3"]
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||||
@ -22,7 +23,7 @@ from .const import (
|
|||||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||||
)
|
)
|
||||||
from .coordinator import HomeConnectConfigEntry
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
from .entity import HomeConnectEntity
|
from .entity import HomeConnectEntity
|
||||||
from .utils import get_dict_from_home_connect_error
|
from .utils import get_dict_from_home_connect_error
|
||||||
|
|
||||||
@ -78,19 +79,28 @@ NUMBERS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
|
||||||
|
for description in NUMBERS
|
||||||
|
if description.key in appliance.settings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect number."""
|
"""Set up the Home Connect number."""
|
||||||
async_add_entities(
|
setup_home_connect_entry(
|
||||||
[
|
entry,
|
||||||
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
|
_get_entities_for_appliance,
|
||||||
for description in NUMBERS
|
async_add_entities,
|
||||||
for appliance in entry.runtime_data.data.values()
|
|
||||||
if description.key in appliance.settings
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
|
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
|
||||||
from .coordinator import (
|
from .coordinator import (
|
||||||
HomeConnectApplianceData,
|
HomeConnectApplianceData,
|
||||||
@ -69,18 +70,31 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
|
||||||
|
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
||||||
|
]
|
||||||
|
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect select entities."""
|
"""Set up the Home Connect select entities."""
|
||||||
|
setup_home_connect_entry(
|
||||||
async_add_entities(
|
entry,
|
||||||
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
|
_get_entities_for_appliance,
|
||||||
for appliance in entry.runtime_data.data.values()
|
async_add_entities,
|
||||||
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
|
||||||
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -110,7 +124,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
|||||||
or program.constraints.execution in desc.allowed_executions
|
or program.constraints.execution in desc.allowed_executions
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
self._attr_current_option = None
|
|
||||||
|
|
||||||
def update_native_value(self) -> None:
|
def update_native_value(self) -> None:
|
||||||
"""Set the program value."""
|
"""Set the program value."""
|
||||||
|
@ -17,13 +17,14 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
from homeassistant.util import dt as dt_util, slugify
|
from homeassistant.util import dt as dt_util, slugify
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
APPLIANCES_WITH_PROGRAMS,
|
APPLIANCES_WITH_PROGRAMS,
|
||||||
BSH_OPERATION_STATE_FINISHED,
|
BSH_OPERATION_STATE_FINISHED,
|
||||||
BSH_OPERATION_STATE_PAUSE,
|
BSH_OPERATION_STATE_PAUSE,
|
||||||
BSH_OPERATION_STATE_RUN,
|
BSH_OPERATION_STATE_RUN,
|
||||||
)
|
)
|
||||||
from .coordinator import HomeConnectConfigEntry
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
from .entity import HomeConnectEntity
|
from .entity import HomeConnectEntity
|
||||||
|
|
||||||
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
||||||
@ -243,37 +244,42 @@ EVENT_SENSORS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
*[
|
||||||
|
HomeConnectEventSensor(entry.runtime_data, appliance, description)
|
||||||
|
for description in EVENT_SENSORS
|
||||||
|
if description.appliance_types
|
||||||
|
and appliance.info.type in description.appliance_types
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
|
||||||
|
for desc in BSH_PROGRAM_SENSORS
|
||||||
|
if desc.appliance_types and appliance.info.type in desc.appliance_types
|
||||||
|
],
|
||||||
|
*[
|
||||||
|
HomeConnectSensor(entry.runtime_data, appliance, description)
|
||||||
|
for description in SENSORS
|
||||||
|
if description.key in appliance.status
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect sensor."""
|
"""Set up the Home Connect sensor."""
|
||||||
|
setup_home_connect_entry(
|
||||||
entities: list[SensorEntity] = []
|
entry,
|
||||||
for appliance in entry.runtime_data.data.values():
|
_get_entities_for_appliance,
|
||||||
entities.extend(
|
async_add_entities,
|
||||||
HomeConnectEventSensor(
|
)
|
||||||
entry.runtime_data,
|
|
||||||
appliance,
|
|
||||||
description,
|
|
||||||
)
|
|
||||||
for description in EVENT_SENSORS
|
|
||||||
if description.appliance_types
|
|
||||||
and appliance.info.type in description.appliance_types
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HomeConnectProgramSensor(entry.runtime_data, appliance, desc)
|
|
||||||
for desc in BSH_PROGRAM_SENSORS
|
|
||||||
if desc.appliance_types and appliance.info.type in desc.appliance_types
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HomeConnectSensor(entry.runtime_data, appliance, description)
|
|
||||||
for description in SENSORS
|
|
||||||
if description.key in appliance.status
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||||
|
@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
|
|||||||
)
|
)
|
||||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
BSH_POWER_OFF,
|
BSH_POWER_OFF,
|
||||||
BSH_POWER_ON,
|
BSH_POWER_ON,
|
||||||
@ -100,33 +101,43 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
entities: list[HomeConnectEntity] = []
|
||||||
|
entities.extend(
|
||||||
|
HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
|
||||||
|
for program in appliance.programs
|
||||||
|
if program.key != ProgramKey.UNKNOWN
|
||||||
|
)
|
||||||
|
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
|
||||||
|
entities.append(
|
||||||
|
HomeConnectPowerSwitch(
|
||||||
|
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entities.extend(
|
||||||
|
HomeConnectSwitch(entry.runtime_data, appliance, description)
|
||||||
|
for description in SWITCHES
|
||||||
|
if description.key in appliance.settings
|
||||||
|
)
|
||||||
|
|
||||||
|
return entities
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect switch."""
|
"""Set up the Home Connect switch."""
|
||||||
|
setup_home_connect_entry(
|
||||||
entities: list[SwitchEntity] = []
|
entry,
|
||||||
for appliance in entry.runtime_data.data.values():
|
_get_entities_for_appliance,
|
||||||
entities.extend(
|
async_add_entities,
|
||||||
HomeConnectProgramSwitch(entry.runtime_data, appliance, program)
|
)
|
||||||
for program in appliance.programs
|
|
||||||
if program.key != ProgramKey.UNKNOWN
|
|
||||||
)
|
|
||||||
if SettingKey.BSH_COMMON_POWER_STATE in appliance.settings:
|
|
||||||
entities.append(
|
|
||||||
HomeConnectPowerSwitch(
|
|
||||||
entry.runtime_data, appliance, POWER_SWITCH_DESCRIPTION
|
|
||||||
)
|
|
||||||
)
|
|
||||||
entities.extend(
|
|
||||||
HomeConnectSwitch(entry.runtime_data, appliance, description)
|
|
||||||
for description in SWITCHES
|
|
||||||
if description.key in appliance.settings
|
|
||||||
)
|
|
||||||
|
|
||||||
async_add_entities(entities)
|
|
||||||
|
|
||||||
|
|
||||||
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
||||||
@ -192,6 +203,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
|||||||
desc = " ".join(
|
desc = " ".join(
|
||||||
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
|
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
|
||||||
)
|
)
|
||||||
|
self.program = program
|
||||||
super().__init__(
|
super().__init__(
|
||||||
coordinator,
|
coordinator,
|
||||||
appliance,
|
appliance,
|
||||||
@ -200,7 +212,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
|||||||
self._attr_name = f"{appliance.info.name} {desc}"
|
self._attr_name = f"{appliance.info.name} {desc}"
|
||||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
|
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
|
||||||
self._attr_has_entity_name = False
|
self._attr_has_entity_name = False
|
||||||
self.program = program
|
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Call when entity is added to hass."""
|
"""Call when entity is added to hass."""
|
||||||
|
@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
|
|||||||
from homeassistant.exceptions import HomeAssistantError
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
|
from .common import setup_home_connect_entry
|
||||||
from .const import (
|
from .const import (
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||||
@ -18,7 +19,7 @@ from .const import (
|
|||||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||||
)
|
)
|
||||||
from .coordinator import HomeConnectConfigEntry
|
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||||
from .entity import HomeConnectEntity
|
from .entity import HomeConnectEntity
|
||||||
from .utils import get_dict_from_home_connect_error
|
from .utils import get_dict_from_home_connect_error
|
||||||
|
|
||||||
@ -30,20 +31,28 @@ TIME_ENTITIES = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_entities_for_appliance(
|
||||||
|
entry: HomeConnectConfigEntry,
|
||||||
|
appliance: HomeConnectApplianceData,
|
||||||
|
) -> list[HomeConnectEntity]:
|
||||||
|
"""Get a list of entities."""
|
||||||
|
return [
|
||||||
|
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
|
||||||
|
for description in TIME_ENTITIES
|
||||||
|
if description.key in appliance.settings
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
entry: HomeConnectConfigEntry,
|
entry: HomeConnectConfigEntry,
|
||||||
async_add_entities: AddEntitiesCallback,
|
async_add_entities: AddEntitiesCallback,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Home Connect switch."""
|
"""Set up the Home Connect switch."""
|
||||||
|
setup_home_connect_entry(
|
||||||
async_add_entities(
|
entry,
|
||||||
[
|
_get_entities_for_appliance,
|
||||||
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
|
async_add_entities,
|
||||||
for description in TIME_ENTITIES
|
|
||||||
for appliance in entry.runtime_data.data.values()
|
|
||||||
if description.key in appliance.settings
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
from pyHomee.const import NodeProfile
|
from pyHomee.const import NodeProfile
|
||||||
|
|
||||||
from homeassistant.const import (
|
from homeassistant.const import (
|
||||||
|
DEGREE,
|
||||||
LIGHT_LUX,
|
LIGHT_LUX,
|
||||||
PERCENTAGE,
|
PERCENTAGE,
|
||||||
REVOLUTIONS_PER_MINUTE,
|
REVOLUTIONS_PER_MINUTE,
|
||||||
@ -34,6 +35,7 @@ HOMEE_UNIT_TO_HA_UNIT = {
|
|||||||
"W": UnitOfPower.WATT,
|
"W": UnitOfPower.WATT,
|
||||||
"m/s": UnitOfSpeed.METERS_PER_SECOND,
|
"m/s": UnitOfSpeed.METERS_PER_SECOND,
|
||||||
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
|
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
|
||||||
|
"°": DEGREE,
|
||||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||||
"°C": UnitOfTemperature.CELSIUS,
|
"°C": UnitOfTemperature.CELSIUS,
|
||||||
"K": UnitOfTemperature.KELVIN,
|
"K": UnitOfTemperature.KELVIN,
|
||||||
@ -53,7 +55,7 @@ OPEN_CLOSE_MAP_REVERSED = {
|
|||||||
0.0: "closed",
|
0.0: "closed",
|
||||||
1.0: "open",
|
1.0: "open",
|
||||||
2.0: "partial",
|
2.0: "partial",
|
||||||
3.0: "cosing",
|
3.0: "closing",
|
||||||
4.0: "opening",
|
4.0: "opening",
|
||||||
}
|
}
|
||||||
WINDOW_MAP = {
|
WINDOW_MAP = {
|
||||||
|
@ -68,6 +68,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
|
|||||||
"""Determine the device class a homee node based on the node profile."""
|
"""Determine the device class a homee node based on the node profile."""
|
||||||
COVER_DEVICE_PROFILES = {
|
COVER_DEVICE_PROFILES = {
|
||||||
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
|
||||||
|
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
|
||||||
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +94,7 @@ def is_cover_node(node: HomeeNode) -> bool:
|
|||||||
return node.profile in [
|
return node.profile in [
|
||||||
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
|
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
|
||||||
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
|
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
|
||||||
|
NodeProfile.ENTRANCE_GATE_OPERATOR,
|
||||||
NodeProfile.GARAGE_DOOR_OPERATOR,
|
NodeProfile.GARAGE_DOOR_OPERATOR,
|
||||||
NodeProfile.SHUTTER_POSITION_SWITCH,
|
NodeProfile.SHUTTER_POSITION_SWITCH,
|
||||||
]
|
]
|
||||||
|
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
|
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
|
||||||
from pyHomee.model import HomeeAttribute, HomeeNode
|
from pyHomee.model import HomeeAttribute, HomeeNode
|
||||||
|
from websockets.exceptions import ConnectionClosed
|
||||||
|
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
from homeassistant.helpers.device_registry import DeviceInfo
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
from homeassistant.helpers.entity import Entity
|
from homeassistant.helpers.entity import Entity
|
||||||
|
|
||||||
@ -135,7 +137,13 @@ class HomeeNodeEntity(Entity):
|
|||||||
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
|
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
|
||||||
"""Set an attribute value on the homee node."""
|
"""Set an attribute value on the homee node."""
|
||||||
homee = self._entry.runtime_data
|
homee = self._entry.runtime_data
|
||||||
await homee.set_value(attribute.node_id, attribute.id, value)
|
try:
|
||||||
|
await homee.set_value(attribute.node_id, attribute.id, value)
|
||||||
|
except ConnectionClosed as exception:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
translation_domain=DOMAIN,
|
||||||
|
translation_key="connection_closed",
|
||||||
|
) from exception
|
||||||
|
|
||||||
def _on_node_updated(self, node: HomeeNode) -> None:
|
def _on_node_updated(self, node: HomeeNode) -> None:
|
||||||
self.schedule_update_ha_state()
|
self.schedule_update_ha_state()
|
||||||
|
@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
|
|||||||
AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
|
AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
|
||||||
key="total_current",
|
key="total_current",
|
||||||
device_class=SensorDeviceClass.CURRENT,
|
device_class=SensorDeviceClass.CURRENT,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
),
|
),
|
||||||
AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
|
AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
|
||||||
key="total_power",
|
key="total_power",
|
||||||
@ -252,7 +253,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
|
|||||||
],
|
],
|
||||||
entity_category=EntityCategory.DIAGNOSTIC,
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
entity_registry_enabled_default=False,
|
entity_registry_enabled_default=False,
|
||||||
translation_key="node_sensor_state",
|
translation_key="node_state",
|
||||||
value_fn=lambda node: get_name_for_enum(NodeState, node.state),
|
value_fn=lambda node: get_name_for_enum(NodeState, node.state),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -67,7 +67,23 @@
|
|||||||
"name": "Link quality"
|
"name": "Link quality"
|
||||||
},
|
},
|
||||||
"node_state": {
|
"node_state": {
|
||||||
"name": "Node state"
|
"name": "Node state",
|
||||||
|
"state": {
|
||||||
|
"available": "Available",
|
||||||
|
"unavailable": "Unavailable",
|
||||||
|
"update_in_progress": "Update in progress",
|
||||||
|
"waiting_for_attributes": "Waiting for attributes",
|
||||||
|
"initializing": "Initializing",
|
||||||
|
"user_interaction_required": "User interaction required",
|
||||||
|
"password_required": "Password required",
|
||||||
|
"host_unavailable": "Host unavailable",
|
||||||
|
"delete_in_progress": "Delete in progress",
|
||||||
|
"cosi_connected": "Cosi connected",
|
||||||
|
"blocked": "Blocked",
|
||||||
|
"waiting_for_wakeup": "Waiting for wakeup",
|
||||||
|
"remote_node_deleted": "Remote node deleted",
|
||||||
|
"firmware_update_in_progress": "Firmware update in progress"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"operating_hours": {
|
"operating_hours": {
|
||||||
"name": "Operating hours"
|
"name": "Operating hours"
|
||||||
@ -192,5 +208,10 @@
|
|||||||
"name": "Watchdog"
|
"name": "Watchdog"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"exceptions": {
|
||||||
|
"connection_closed": {
|
||||||
|
"message": "Could not connect to Homee while setting attribute"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,6 +78,7 @@ from .const import (
|
|||||||
CONF_VIDEO_CODEC,
|
CONF_VIDEO_CODEC,
|
||||||
CONF_VIDEO_MAP,
|
CONF_VIDEO_MAP,
|
||||||
CONF_VIDEO_PACKET_SIZE,
|
CONF_VIDEO_PACKET_SIZE,
|
||||||
|
CONF_VIDEO_PROFILE_NAMES,
|
||||||
DEFAULT_AUDIO_CODEC,
|
DEFAULT_AUDIO_CODEC,
|
||||||
DEFAULT_AUDIO_MAP,
|
DEFAULT_AUDIO_MAP,
|
||||||
DEFAULT_AUDIO_PACKET_SIZE,
|
DEFAULT_AUDIO_PACKET_SIZE,
|
||||||
@ -90,6 +91,7 @@ from .const import (
|
|||||||
DEFAULT_VIDEO_CODEC,
|
DEFAULT_VIDEO_CODEC,
|
||||||
DEFAULT_VIDEO_MAP,
|
DEFAULT_VIDEO_MAP,
|
||||||
DEFAULT_VIDEO_PACKET_SIZE,
|
DEFAULT_VIDEO_PACKET_SIZE,
|
||||||
|
DEFAULT_VIDEO_PROFILE_NAMES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
FEATURE_ON_OFF,
|
FEATURE_ON_OFF,
|
||||||
FEATURE_PLAY_PAUSE,
|
FEATURE_PLAY_PAUSE,
|
||||||
@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
|
|||||||
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
|
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
|
||||||
VALID_VIDEO_CODECS
|
VALID_VIDEO_CODECS
|
||||||
),
|
),
|
||||||
|
vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [
|
||||||
|
cv.string
|
||||||
|
],
|
||||||
vol.Optional(
|
vol.Optional(
|
||||||
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
|
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
|
||||||
): cv.positive_int,
|
): cv.positive_int,
|
||||||
|
@ -25,7 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
|
|||||||
|
|
||||||
api: HomeWizardEnergy
|
api: HomeWizardEnergy
|
||||||
|
|
||||||
if token := entry.data.get(CONF_TOKEN):
|
is_battery = entry.unique_id.startswith("HWE-BAT") if entry.unique_id else False
|
||||||
|
|
||||||
|
if (token := entry.data.get(CONF_TOKEN)) and is_battery:
|
||||||
api = HomeWizardEnergyV2(
|
api = HomeWizardEnergyV2(
|
||||||
entry.data[CONF_IP_ADDRESS],
|
entry.data[CONF_IP_ADDRESS],
|
||||||
token=token,
|
token=token,
|
||||||
@ -37,7 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
|
|||||||
clientsession=async_get_clientsession(hass),
|
clientsession=async_get_clientsession(hass),
|
||||||
)
|
)
|
||||||
|
|
||||||
await async_check_v2_support_and_create_issue(hass, entry)
|
if is_battery:
|
||||||
|
await async_check_v2_support_and_create_issue(hass, entry)
|
||||||
|
|
||||||
coordinator = HWEnergyDeviceUpdateCoordinator(hass, api)
|
coordinator = HWEnergyDeviceUpdateCoordinator(hass, api)
|
||||||
try:
|
try:
|
||||||
|
@ -272,9 +272,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
"""Handle reconfiguration of the integration."""
|
"""Handle reconfiguration of the integration."""
|
||||||
errors: dict[str, str] = {}
|
errors: dict[str, str] = {}
|
||||||
|
reconfigure_entry = self._get_reconfigure_entry()
|
||||||
|
|
||||||
if user_input:
|
if user_input:
|
||||||
try:
|
try:
|
||||||
device_info = await async_try_connect(user_input[CONF_IP_ADDRESS])
|
device_info = await async_try_connect(
|
||||||
|
user_input[CONF_IP_ADDRESS],
|
||||||
|
token=reconfigure_entry.data.get(CONF_TOKEN),
|
||||||
|
)
|
||||||
|
|
||||||
except RecoverableError as ex:
|
except RecoverableError as ex:
|
||||||
LOGGER.error(ex)
|
LOGGER.error(ex)
|
||||||
@ -288,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
self._get_reconfigure_entry(),
|
self._get_reconfigure_entry(),
|
||||||
data_updates=user_input,
|
data_updates=user_input,
|
||||||
)
|
)
|
||||||
reconfigure_entry = self._get_reconfigure_entry()
|
|
||||||
return self.async_show_form(
|
return self.async_show_form(
|
||||||
step_id="reconfigure",
|
step_id="reconfigure",
|
||||||
data_schema=vol.Schema(
|
data_schema=vol.Schema(
|
||||||
@ -306,7 +310,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_try_connect(ip_address: str) -> Device:
|
async def async_try_connect(ip_address: str, token: str | None = None) -> Device:
|
||||||
"""Try to connect.
|
"""Try to connect.
|
||||||
|
|
||||||
Make connection with device to test the connection
|
Make connection with device to test the connection
|
||||||
@ -317,7 +321,7 @@ async def async_try_connect(ip_address: str) -> Device:
|
|||||||
|
|
||||||
# Determine if device is v1 or v2 capable
|
# Determine if device is v1 or v2 capable
|
||||||
if await has_v2_api(ip_address):
|
if await has_v2_api(ip_address):
|
||||||
energy_api = HomeWizardEnergyV2(ip_address)
|
energy_api = HomeWizardEnergyV2(ip_address, token=token)
|
||||||
else:
|
else:
|
||||||
energy_api = HomeWizardEnergyV1(ip_address)
|
energy_api = HomeWizardEnergyV1(ip_address)
|
||||||
|
|
||||||
|
@ -408,7 +408,7 @@ class HueLight(CoordinatorEntity, LightEntity):
|
|||||||
if self._fixed_color_mode:
|
if self._fixed_color_mode:
|
||||||
return self._fixed_color_mode
|
return self._fixed_color_mode
|
||||||
|
|
||||||
# The light supports both hs/xy and white with adjustabe color_temperature
|
# The light supports both hs/xy and white with adjustable color_temperature
|
||||||
mode = self._color_mode
|
mode = self._color_mode
|
||||||
if mode in ("xy", "hs"):
|
if mode in ("xy", "hs"):
|
||||||
return ColorMode.HS
|
return ColorMode.HS
|
||||||
|
@ -87,7 +87,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
if discovery := self._discovery_info:
|
if discovery := self._discovery_info:
|
||||||
self._discovered_devices[discovery.address] = discovery
|
self._discovered_devices[discovery.address] = discovery
|
||||||
else:
|
else:
|
||||||
current_addresses = self._async_current_ids()
|
current_addresses = self._async_current_ids(include_ignore=False)
|
||||||
for discovery in async_discovered_service_info(self.hass):
|
for discovery in async_discovered_service_info(self.hass):
|
||||||
if (
|
if (
|
||||||
discovery.address in current_addresses
|
discovery.address in current_addresses
|
||||||
|
@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
|
|||||||
title=self._discovered_devices[address], data={}
|
title=self._discovered_devices[address], 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):
|
for discovery_info in async_discovered_service_info(self.hass, False):
|
||||||
address = discovery_info.address
|
address = discovery_info.address
|
||||||
if address in current_addresses or address in self._discovered_devices:
|
if address in current_addresses or address in self._discovered_devices:
|
||||||
|
39
homeassistant/components/iometer/__init__.py
Normal file
39
homeassistant/components/iometer/__init__.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""The IOmeter integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from iometer import IOmeterClient, IOmeterConnectionError
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import CONF_HOST, Platform
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.exceptions import ConfigEntryNotReady
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
|
||||||
|
from .coordinator import IOmeterConfigEntry, IOMeterCoordinator
|
||||||
|
|
||||||
|
PLATFORMS: list[Platform] = [Platform.SENSOR]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(hass: HomeAssistant, entry: IOmeterConfigEntry) -> bool:
|
||||||
|
"""Set up IOmeter from a config entry."""
|
||||||
|
|
||||||
|
host = entry.data[CONF_HOST]
|
||||||
|
session = async_get_clientsession(hass)
|
||||||
|
client = IOmeterClient(host=host, session=session)
|
||||||
|
try:
|
||||||
|
await client.get_current_status()
|
||||||
|
except IOmeterConnectionError as err:
|
||||||
|
raise ConfigEntryNotReady from err
|
||||||
|
|
||||||
|
coordinator = IOMeterCoordinator(hass, client)
|
||||||
|
await coordinator.async_config_entry_first_refresh()
|
||||||
|
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:
|
||||||
|
"""Unload a config entry."""
|
||||||
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
91
homeassistant/components/iometer/config_flow.py
Normal file
91
homeassistant/components/iometer/config_flow.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"""Config flow for the IOmeter integration."""
|
||||||
|
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from iometer import IOmeterClient, IOmeterConnectionError
|
||||||
|
import voluptuous as vol
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||||
|
from homeassistant.const import CONF_HOST
|
||||||
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||||
|
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
CONFIG_SCHEMA: Final = vol.Schema({vol.Required(CONF_HOST): str})
|
||||||
|
|
||||||
|
|
||||||
|
class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||||
|
"""Handles the config flow for a IOmeter bridge and core."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Initialize the config flow."""
|
||||||
|
self._host: str
|
||||||
|
self._meter_number: str
|
||||||
|
|
||||||
|
async def async_step_zeroconf(
|
||||||
|
self, discovery_info: ZeroconfServiceInfo
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle zeroconf discovery."""
|
||||||
|
self._host = host = discovery_info.host
|
||||||
|
self._async_abort_entries_match({CONF_HOST: host})
|
||||||
|
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
client = IOmeterClient(host=host, session=session)
|
||||||
|
try:
|
||||||
|
status = await client.get_current_status()
|
||||||
|
except IOmeterConnectionError:
|
||||||
|
return self.async_abort(reason="cannot_connect")
|
||||||
|
|
||||||
|
self._meter_number = status.meter.number
|
||||||
|
|
||||||
|
await self.async_set_unique_id(status.device.id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
|
||||||
|
self.context["title_placeholders"] = {"name": f"IOmeter {self._meter_number}"}
|
||||||
|
return await self.async_step_zeroconf_confirm()
|
||||||
|
|
||||||
|
async def async_step_zeroconf_confirm(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Confirm discovery."""
|
||||||
|
if user_input is not None:
|
||||||
|
return await self._async_create_entry()
|
||||||
|
|
||||||
|
self._set_confirm_only()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="zeroconf_confirm",
|
||||||
|
description_placeholders={"meter_number": self._meter_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_step_user(
|
||||||
|
self, user_input: dict[str, Any] | None = None
|
||||||
|
) -> ConfigFlowResult:
|
||||||
|
"""Handle the initial configuration."""
|
||||||
|
errors: dict[str, str] = {}
|
||||||
|
|
||||||
|
if user_input is not None:
|
||||||
|
self._host = user_input[CONF_HOST]
|
||||||
|
session = async_get_clientsession(self.hass)
|
||||||
|
client = IOmeterClient(host=self._host, session=session)
|
||||||
|
try:
|
||||||
|
status = await client.get_current_status()
|
||||||
|
except IOmeterConnectionError:
|
||||||
|
errors["base"] = "cannot_connect"
|
||||||
|
else:
|
||||||
|
self._meter_number = status.meter.number
|
||||||
|
await self.async_set_unique_id(status.device.id)
|
||||||
|
self._abort_if_unique_id_configured()
|
||||||
|
return await self._async_create_entry()
|
||||||
|
return self.async_show_form(
|
||||||
|
step_id="user",
|
||||||
|
data_schema=CONFIG_SCHEMA,
|
||||||
|
errors=errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _async_create_entry(self) -> ConfigFlowResult:
|
||||||
|
"""Create entry."""
|
||||||
|
return self.async_create_entry(
|
||||||
|
title=f"IOmeter {self._meter_number}",
|
||||||
|
data={CONF_HOST: self._host},
|
||||||
|
)
|
5
homeassistant/components/iometer/const.py
Normal file
5
homeassistant/components/iometer/const.py
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
"""Constants for the IOmeter integration."""
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
DOMAIN: Final = "iometer"
|
55
homeassistant/components/iometer/coordinator.py
Normal file
55
homeassistant/components/iometer/coordinator.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"""DataUpdateCoordinator for IOmeter."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import timedelta
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from iometer import IOmeterClient, IOmeterConnectionError, Reading, Status
|
||||||
|
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
DEFAULT_SCAN_INTERVAL = timedelta(seconds=10)
|
||||||
|
|
||||||
|
type IOmeterConfigEntry = ConfigEntry[IOMeterCoordinator]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IOmeterData:
|
||||||
|
"""Class for data update."""
|
||||||
|
|
||||||
|
reading: Reading
|
||||||
|
status: Status
|
||||||
|
|
||||||
|
|
||||||
|
class IOMeterCoordinator(DataUpdateCoordinator[IOmeterData]):
|
||||||
|
"""Class to manage fetching IOmeter data."""
|
||||||
|
|
||||||
|
config_entry: IOmeterConfigEntry
|
||||||
|
client: IOmeterClient
|
||||||
|
|
||||||
|
def __init__(self, hass: HomeAssistant, client: IOmeterClient) -> None:
|
||||||
|
"""Initialize coordinator."""
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
hass,
|
||||||
|
_LOGGER,
|
||||||
|
name=DOMAIN,
|
||||||
|
update_interval=DEFAULT_SCAN_INTERVAL,
|
||||||
|
)
|
||||||
|
self.client = client
|
||||||
|
self.identifier = self.config_entry.entry_id
|
||||||
|
|
||||||
|
async def _async_update_data(self) -> IOmeterData:
|
||||||
|
"""Update data async."""
|
||||||
|
try:
|
||||||
|
reading = await self.client.get_current_reading()
|
||||||
|
status = await self.client.get_current_status()
|
||||||
|
except IOmeterConnectionError as error:
|
||||||
|
raise UpdateFailed(f"Error communicating with IOmeter: {error}") from error
|
||||||
|
|
||||||
|
return IOmeterData(reading=reading, status=status)
|
24
homeassistant/components/iometer/entity.py
Normal file
24
homeassistant/components/iometer/entity.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Base class for IOmeter entities."""
|
||||||
|
|
||||||
|
from homeassistant.helpers.device_registry import DeviceInfo
|
||||||
|
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||||
|
|
||||||
|
from .const import DOMAIN
|
||||||
|
from .coordinator import IOMeterCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]):
|
||||||
|
"""Defines a base IOmeter entity."""
|
||||||
|
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
|
def __init__(self, coordinator: IOMeterCoordinator) -> None:
|
||||||
|
"""Initialize IOmeter entity."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
status = coordinator.data.status
|
||||||
|
self._attr_device_info = DeviceInfo(
|
||||||
|
identifiers={(DOMAIN, status.device.id)},
|
||||||
|
manufacturer="IOmeter GmbH",
|
||||||
|
model="IOmeter",
|
||||||
|
sw_version=f"{status.device.core.version}/{status.device.bridge.version}",
|
||||||
|
)
|
38
homeassistant/components/iometer/icons.json
Normal file
38
homeassistant/components/iometer/icons.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"attachment_status": {
|
||||||
|
"default": "mdi:eye",
|
||||||
|
"state": {
|
||||||
|
"attached": "mdi:check-bold",
|
||||||
|
"detached": "mdi:close",
|
||||||
|
"unknown": "mdi:help"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"connection_status": {
|
||||||
|
"default": "mdi:eye",
|
||||||
|
"state": {
|
||||||
|
"connected": "mdi:check-bold",
|
||||||
|
"disconnected": "mdi:close",
|
||||||
|
"unknown": "mdi:help"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pin_status": {
|
||||||
|
"default": "mdi:eye",
|
||||||
|
"state": {
|
||||||
|
"entered": "mdi:lock-open",
|
||||||
|
"pending": "mdi:lock-clock",
|
||||||
|
"missing": "mdi:lock",
|
||||||
|
"unknown": "mdi:help"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"power_status": {
|
||||||
|
"default": "mdi:eye",
|
||||||
|
"state": {
|
||||||
|
"battery": "mdi:battery",
|
||||||
|
"wired": "mdi:power-plug"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
12
homeassistant/components/iometer/manifest.json
Normal file
12
homeassistant/components/iometer/manifest.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"domain": "iometer",
|
||||||
|
"name": "IOmeter",
|
||||||
|
"codeowners": ["@MaestroOnICe"],
|
||||||
|
"config_flow": true,
|
||||||
|
"documentation": "https://www.home-assistant.io/integrations/iometer",
|
||||||
|
"integration_type": "device",
|
||||||
|
"iot_class": "local_polling",
|
||||||
|
"quality_scale": "bronze",
|
||||||
|
"requirements": ["iometer==0.1.0"],
|
||||||
|
"zeroconf": ["_iometer._tcp.local."]
|
||||||
|
}
|
74
homeassistant/components/iometer/quality_scale.yaml
Normal file
74
homeassistant/components/iometer/quality_scale.yaml
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
rules:
|
||||||
|
# Bronze
|
||||||
|
action-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not provide additional actions.
|
||||||
|
appropriate-polling: done
|
||||||
|
brands: done
|
||||||
|
common-modules: done
|
||||||
|
config-flow-test-coverage: done
|
||||||
|
config-flow: done
|
||||||
|
dependency-transparency: done
|
||||||
|
docs-actions:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not provide additional actions.
|
||||||
|
docs-high-level-description: done
|
||||||
|
docs-installation-instructions: done
|
||||||
|
docs-removal-instructions: done
|
||||||
|
entity-event-setup:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not register any events.
|
||||||
|
entity-unique-id: done
|
||||||
|
has-entity-name: done
|
||||||
|
runtime-data: done
|
||||||
|
test-before-configure: done
|
||||||
|
test-before-setup: done
|
||||||
|
unique-config-entry: done
|
||||||
|
|
||||||
|
# Silver
|
||||||
|
action-exceptions:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration does not provide additional actions.
|
||||||
|
config-entry-unloading: done
|
||||||
|
docs-configuration-parameters:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration has not option flow.
|
||||||
|
docs-installation-parameters: done
|
||||||
|
entity-unavailable: done
|
||||||
|
integration-owner: done
|
||||||
|
log-when-unavailable: done
|
||||||
|
parallel-updates:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration polls data using a coordinator, there is no need for parallel updates.
|
||||||
|
reauthentication-flow:
|
||||||
|
status: exempt
|
||||||
|
comment: This integration requires no authentication.
|
||||||
|
test-coverage: todo
|
||||||
|
|
||||||
|
# Gold
|
||||||
|
devices: todo
|
||||||
|
diagnostics: todo
|
||||||
|
discovery-update-info: todo
|
||||||
|
discovery: done
|
||||||
|
docs-data-update: todo
|
||||||
|
docs-examples: todo
|
||||||
|
docs-known-limitations: todo
|
||||||
|
docs-supported-devices: todo
|
||||||
|
docs-supported-functions: todo
|
||||||
|
docs-troubleshooting: todo
|
||||||
|
docs-use-cases: todo
|
||||||
|
dynamic-devices: todo
|
||||||
|
entity-category: done
|
||||||
|
entity-device-class: done
|
||||||
|
entity-disabled-by-default: done
|
||||||
|
entity-translations: done
|
||||||
|
exception-translations: todo
|
||||||
|
icon-translations: todo
|
||||||
|
reconfiguration-flow: todo
|
||||||
|
repair-issues: todo
|
||||||
|
stale-devices: todo
|
||||||
|
|
||||||
|
# Platinum
|
||||||
|
async-dependency: done
|
||||||
|
inject-websession: done
|
||||||
|
strict-typing: todo
|
146
homeassistant/components/iometer/sensor.py
Normal file
146
homeassistant/components/iometer/sensor.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""IOmeter sensors."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from homeassistant.components.sensor import (
|
||||||
|
SensorDeviceClass,
|
||||||
|
SensorEntity,
|
||||||
|
SensorEntityDescription,
|
||||||
|
SensorStateClass,
|
||||||
|
)
|
||||||
|
from homeassistant.config_entries import ConfigEntry
|
||||||
|
from homeassistant.const import (
|
||||||
|
PERCENTAGE,
|
||||||
|
SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
STATE_UNKNOWN,
|
||||||
|
EntityCategory,
|
||||||
|
UnitOfEnergy,
|
||||||
|
UnitOfPower,
|
||||||
|
)
|
||||||
|
from homeassistant.core import HomeAssistant
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import StateType
|
||||||
|
|
||||||
|
from .coordinator import IOMeterCoordinator, IOmeterData
|
||||||
|
from .entity import IOmeterEntity
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, kw_only=True)
|
||||||
|
class IOmeterEntityDescription(SensorEntityDescription):
|
||||||
|
"""Describes IOmeter sensor entity."""
|
||||||
|
|
||||||
|
value_fn: Callable[[IOmeterData], str | int | float]
|
||||||
|
|
||||||
|
|
||||||
|
SENSOR_TYPES: list[IOmeterEntityDescription] = [
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="meter_number",
|
||||||
|
translation_key="meter_number",
|
||||||
|
icon="mdi:meter-electric",
|
||||||
|
value_fn=lambda data: data.status.meter.number,
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="wifi_rssi",
|
||||||
|
translation_key="wifi_rssi",
|
||||||
|
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda data: data.status.device.bridge.rssi,
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="core_bridge_rssi",
|
||||||
|
translation_key="core_bridge_rssi",
|
||||||
|
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
|
||||||
|
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
entity_category=EntityCategory.DIAGNOSTIC,
|
||||||
|
entity_registry_enabled_default=False,
|
||||||
|
value_fn=lambda data: data.status.device.core.rssi,
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="power_status",
|
||||||
|
translation_key="power_status",
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
options=["battery", "wired", "unknown"],
|
||||||
|
value_fn=lambda data: data.status.device.core.power_status or STATE_UNKNOWN,
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="battery_level",
|
||||||
|
translation_key="battery_level",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.status.device.core.battery_level,
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="pin_status",
|
||||||
|
translation_key="pin_status",
|
||||||
|
device_class=SensorDeviceClass.ENUM,
|
||||||
|
options=["entered", "pending", "missing", "unknown"],
|
||||||
|
value_fn=lambda data: data.status.device.core.pin_status or STATE_UNKNOWN,
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="total_consumption",
|
||||||
|
translation_key="total_consumption",
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
value_fn=lambda data: data.reading.get_total_consumption(),
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="total_production",
|
||||||
|
translation_key="total_production",
|
||||||
|
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||||
|
device_class=SensorDeviceClass.ENERGY,
|
||||||
|
state_class=SensorStateClass.TOTAL,
|
||||||
|
value_fn=lambda data: data.reading.get_total_production(),
|
||||||
|
),
|
||||||
|
IOmeterEntityDescription(
|
||||||
|
key="power",
|
||||||
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
|
device_class=SensorDeviceClass.POWER,
|
||||||
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
|
value_fn=lambda data: data.reading.get_current_power(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_entry(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config_entry: ConfigEntry,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the Sensors."""
|
||||||
|
coordinator: IOMeterCoordinator = config_entry.runtime_data
|
||||||
|
|
||||||
|
async_add_entities(
|
||||||
|
IOmeterSensor(
|
||||||
|
coordinator=coordinator,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
for description in SENSOR_TYPES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IOmeterSensor(IOmeterEntity, SensorEntity):
|
||||||
|
"""Defines a IOmeter sensor."""
|
||||||
|
|
||||||
|
entity_description: IOmeterEntityDescription
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
coordinator: IOMeterCoordinator,
|
||||||
|
description: IOmeterEntityDescription,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the sensor."""
|
||||||
|
super().__init__(coordinator)
|
||||||
|
self.entity_description = description
|
||||||
|
self._attr_unique_id = f"{coordinator.identifier}_{description.key}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def native_value(self) -> StateType:
|
||||||
|
"""Return the sensor value."""
|
||||||
|
return self.entity_description.value_fn(self.coordinator.data)
|
65
homeassistant/components/iometer/strings.json
Normal file
65
homeassistant/components/iometer/strings.json
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"step": {
|
||||||
|
"user": {
|
||||||
|
"description": "Setup your IOmeter device for local data",
|
||||||
|
"data": {
|
||||||
|
"host": "[%key:common::config_flow::data::host%]"
|
||||||
|
},
|
||||||
|
"data_description": {
|
||||||
|
"host": "The hostname or IP address of the IOmeter device to connect to."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zeroconf_confirm": {
|
||||||
|
"title": "Discovered IOmeter",
|
||||||
|
"description": "Do you want to set up IOmeter on the meter with meter number: {meter_number}?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"abort": {
|
||||||
|
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||||
|
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||||
|
"unknown": "Unexpected error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entity": {
|
||||||
|
"sensor": {
|
||||||
|
"battery_level": {
|
||||||
|
"name": "Battery level"
|
||||||
|
},
|
||||||
|
"meter_number": {
|
||||||
|
"name": "Meter number"
|
||||||
|
},
|
||||||
|
"pin_status": {
|
||||||
|
"name": "PIN status",
|
||||||
|
"state": {
|
||||||
|
"entered": "Entered",
|
||||||
|
"pending": "Pending",
|
||||||
|
"missing": "Missing",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"power_status": {
|
||||||
|
"name": "Power supply",
|
||||||
|
"state": {
|
||||||
|
"battery": "Battery",
|
||||||
|
"wired": "Wired"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"total_consumption": {
|
||||||
|
"name": "Total consumption"
|
||||||
|
},
|
||||||
|
"total_production": {
|
||||||
|
"name": "Total production"
|
||||||
|
},
|
||||||
|
"core_bridge_rssi": {
|
||||||
|
"name": "Signal strength Core/Bridge"
|
||||||
|
},
|
||||||
|
"wifi_rssi": {
|
||||||
|
"name": "Signal strength Wi-Fi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user