Merge branch 'home-assistant:dev' into homee-switch

This commit is contained in:
Markus Adrario 2025-02-05 07:24:49 +01:00 committed by GitHub
commit 3299bb914a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
375 changed files with 10509 additions and 4210 deletions

View File

@ -8,7 +8,7 @@ repos:
- id: ruff-format
files: ^((homeassistant|pylint|script|tests)/.+)?[^/]+\.(py|pyi)$
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.4.1
hooks:
- id: codespell
args:

6
CODEOWNERS generated
View File

@ -731,6 +731,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam
/tests/components/intent/ @home-assistant/core @synesthesiam
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @MaestroOnICe
/tests/components/iometer/ @MaestroOnICe
/homeassistant/components/ios/ @robbiet480
/tests/components/ios/ @robbiet480
/homeassistant/components/iotawatt/ @gtdiehl @jyavenard
@ -765,8 +767,8 @@ build.json @home-assistant/supervisor
/tests/components/ituran/ @shmuelzon
/homeassistant/components/izone/ @Swamp-Ig
/tests/components/izone/ @Swamp-Ig
/homeassistant/components/jellyfin/ @j-stienstra @ctalkington
/tests/components/jellyfin/ @j-stienstra @ctalkington
/homeassistant/components/jellyfin/ @RunC0deRun @ctalkington
/tests/components/jellyfin/ @RunC0deRun @ctalkington
/homeassistant/components/jewish_calendar/ @tsvi
/tests/components/jewish_calendar/ @tsvi
/homeassistant/components/juicenet/ @jesserockz

2
Dockerfile generated
View File

@ -13,7 +13,7 @@ ENV \
ARG QEMU_CPU
# Install uv
RUN pip3 install uv==0.5.21
RUN pip3 install uv==0.5.27
WORKDIR /usr/src

View File

@ -144,7 +144,7 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_create_entry(title=discovery.name, data={})
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["python_homeassistant_analytics"],
"requirements": ["python-homeassistant-analytics==0.8.1"],
"requirements": ["python-homeassistant-analytics==0.9.0"],
"single_config_entry": true
}

View File

@ -272,6 +272,7 @@ class AnthropicConversationEntity(
continue
tool_input = llm.ToolInput(
id=tool_call.id,
tool_name=tool_call.name,
tool_args=cast(dict[str, Any], tool_call.input),
)

View File

@ -134,7 +134,7 @@ class AppleTVConfigFlow(ConfigFlow, domain=DOMAIN):
unique_id for said entry. When a new (zeroconf) service or device is
discovered, the identifier is first used to look up if it belongs to an
existing config entry. If that's the case, the unique_id from that entry is
re-used, otherwise the newly discovered identifier is used instead.
reused, otherwise the newly discovered identifier is used instead.
"""
assert self.atv
all_identifiers = set(self.atv.all_identifiers)

View File

@ -92,7 +92,7 @@ class AranetConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address][0], data={}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aranet4==2.5.0"]
"requirements": ["aranet4==2.5.1"]
}

View File

@ -9,6 +9,7 @@ import voluptuous as vol
from homeassistant.components import stt
from homeassistant.core import Context, HomeAssistant
from homeassistant.helpers import chat_session
from homeassistant.helpers.typing import ConfigType
from .const import (
@ -114,24 +115,25 @@ async def async_pipeline_from_audio_stream(
Raises PipelineNotFound if no pipeline is found.
"""
pipeline_input = PipelineInput(
conversation_id=conversation_id,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
start_stage=start_stage,
end_stage=end_stage,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
wake_word_settings=wake_word_settings,
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.validate()
await pipeline_input.execute()
with chat_session.async_get_chat_session(hass, conversation_id) as session:
pipeline_input = PipelineInput(
conversation_id=session.conversation_id,
device_id=device_id,
stt_metadata=stt_metadata,
stt_stream=stt_stream,
wake_word_phrase=wake_word_phrase,
conversation_extra_system_prompt=conversation_extra_system_prompt,
run=PipelineRun(
hass,
context=context,
pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id),
start_stage=start_stage,
end_stage=end_stage,
event_callback=event_callback,
tts_audio_output=tts_audio_output,
wake_word_settings=wake_word_settings,
audio_settings=audio_settings or AudioSettings(),
),
)
await pipeline_input.validate()
await pipeline_input.execute()

View File

@ -33,7 +33,7 @@ from homeassistant.components.tts import (
from homeassistant.const import MATCH_ALL
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import intent
from homeassistant.helpers import chat_session, intent
from homeassistant.helpers.collection import (
CHANGE_UPDATED,
CollectionError,
@ -624,7 +624,7 @@ class PipelineRun:
return
pipeline_data.pipeline_debug[self.pipeline.id][self.id].events.append(event)
def start(self, device_id: str | None) -> None:
def start(self, conversation_id: str, device_id: str | None) -> None:
"""Emit run start event."""
self._device_id = device_id
self._start_debug_recording_thread()
@ -632,6 +632,7 @@ class PipelineRun:
data = {
"pipeline": self.pipeline.id,
"language": self.language,
"conversation_id": conversation_id,
}
if self.runner_data is not None:
data["runner_data"] = self.runner_data
@ -1015,7 +1016,7 @@ class PipelineRun:
async def recognize_intent(
self,
intent_input: str,
conversation_id: str | None,
conversation_id: str,
device_id: str | None,
conversation_extra_system_prompt: str | None,
) -> str:
@ -1063,11 +1064,11 @@ class PipelineRun:
agent_id=self.intent_agent,
extra_system_prompt=conversation_extra_system_prompt,
)
processed_locally = self.intent_agent == conversation.HOME_ASSISTANT_AGENT
agent_id = user_input.agent_id
agent_id = self.intent_agent
processed_locally = agent_id == conversation.HOME_ASSISTANT_AGENT
intent_response: intent.IntentResponse | None = None
if user_input.agent_id != conversation.HOME_ASSISTANT_AGENT:
if not processed_locally:
# Sentence triggers override conversation agent
if (
trigger_response_text
@ -1094,22 +1095,26 @@ class PipelineRun:
# It was already handled, create response and add to chat history
if intent_response is not None:
async with conversation.async_get_chat_session(
self.hass, user_input
) as chat_session:
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(
self.hass, session, user_input
) as chat_log,
):
speech: str = intent_response.speech.get("plain", {}).get(
"speech", ""
)
chat_session.async_add_message(
conversation.Content(
role="assistant",
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=agent_id,
content=speech,
)
)
conversation_result = conversation.ConversationResult(
response=intent_response,
conversation_id=chat_session.conversation_id,
conversation_id=session.conversation_id,
)
else:
@ -1404,12 +1409,15 @@ def _pipeline_debug_recording_thread_proc(
wav_writer.close()
@dataclass
@dataclass(kw_only=True)
class PipelineInput:
"""Input to a pipeline run."""
run: PipelineRun
conversation_id: str
"""Identifier for the conversation."""
stt_metadata: stt.SpeechMetadata | None = None
"""Metadata of stt input audio. Required when start_stage = stt."""
@ -1425,9 +1433,6 @@ class PipelineInput:
tts_input: str | None = None
"""Input for text-to-speech. Required when start_stage = tts."""
conversation_id: str | None = None
"""Identifier for the conversation."""
conversation_extra_system_prompt: str | None = None
"""Extra prompt information for the conversation agent."""
@ -1436,7 +1441,7 @@ class PipelineInput:
async def execute(self) -> None:
"""Run pipeline."""
self.run.start(device_id=self.device_id)
self.run.start(conversation_id=self.conversation_id, device_id=self.device_id)
current_stage: PipelineStage | None = self.run.start_stage
stt_audio_buffer: list[EnhancedAudioChunk] = []
stt_processed_stream: AsyncIterable[EnhancedAudioChunk] | None = None

View File

@ -14,7 +14,11 @@ import voluptuous as vol
from homeassistant.components import conversation, stt, tts, websocket_api
from homeassistant.const import ATTR_DEVICE_ID, ATTR_SECONDS, MATCH_ALL
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import config_validation as cv, entity_registry as er
from homeassistant.helpers import (
chat_session,
config_validation as cv,
entity_registry as er,
)
from homeassistant.util import language as language_util
from .const import (
@ -145,7 +149,6 @@ async def websocket_run(
# Arguments to PipelineInput
input_args: dict[str, Any] = {
"conversation_id": msg.get("conversation_id"),
"device_id": msg.get("device_id"),
}
@ -233,38 +236,42 @@ async def websocket_run(
audio_settings=audio_settings or AudioSettings(),
)
pipeline_input = PipelineInput(**input_args)
with chat_session.async_get_chat_session(
hass, msg.get("conversation_id")
) as session:
input_args["conversation_id"] = session.conversation_id
pipeline_input = PipelineInput(**input_args)
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
try:
await pipeline_input.validate()
except PipelineError as error:
# Report more specific error when possible
connection.send_error(msg["id"], error.code, error.message)
return
# Confirm subscription
connection.send_result(msg["id"])
# Confirm subscription
connection.send_result(msg["id"])
run_task = hass.async_create_task(pipeline_input.execute())
run_task = hass.async_create_task(pipeline_input.execute())
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
# Cancel pipeline if user unsubscribes
connection.subscriptions[msg["id"]] = run_task.cancel
try:
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
try:
# Task contains a timeout
async with asyncio.timeout(timeout):
await run_task
except TimeoutError:
pipeline_input.run.process_event(
PipelineEvent(
PipelineEventType.ERROR,
{"code": "timeout", "message": "Timeout running pipeline"},
)
)
)
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
finally:
if unregister_handler is not None:
# Unregister binary handler
unregister_handler()
@callback

View File

@ -8,7 +8,7 @@ from dataclasses import dataclass
from enum import StrEnum
import logging
import time
from typing import Any, Final, Literal, final
from typing import Any, Literal, final
from homeassistant.components import conversation, media_source, stt, tts
from homeassistant.components.assist_pipeline import (
@ -28,14 +28,12 @@ from homeassistant.components.tts import (
)
from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity
from homeassistant.helpers import chat_session, entity
from homeassistant.helpers.entity import EntityDescription
from .const import AssistSatelliteEntityFeature
from .errors import AssistSatelliteError, SatelliteBusyError
_CONVERSATION_TIMEOUT_SEC: Final = 5 * 60 # 5 minutes
_LOGGER = logging.getLogger(__name__)
@ -114,7 +112,6 @@ class AssistSatelliteEntity(entity.Entity):
_attr_vad_sensitivity_entity_id: str | None = None
_conversation_id: str | None = None
_conversation_id_time: float | None = None
_run_has_tts: bool = False
_is_announcing = False
@ -260,8 +257,27 @@ class AssistSatelliteEntity(entity.Entity):
else:
self._extra_system_prompt = start_message or None
with (
# Not passing in a conversation ID will force a new one to be created
chat_session.async_get_chat_session(self.hass) as session,
conversation.async_get_chat_log(self.hass, session) as chat_log,
):
self._conversation_id = session.conversation_id
if start_message:
chat_log.async_add_assistant_content_without_tools(
conversation.AssistantContent(
agent_id=self.entity_id, content=start_message
)
)
try:
await self.async_start_conversation(announcement)
except Exception:
# Clear prompt on error
self._conversation_id = None
self._extra_system_prompt = None
raise
finally:
self._is_announcing = False
@ -325,51 +341,52 @@ class AssistSatelliteEntity(entity.Entity):
assert self._context is not None
# Reset conversation id if necessary
if self._conversation_id_time and (
(time.monotonic() - self._conversation_id_time) > _CONVERSATION_TIMEOUT_SEC
):
self._conversation_id = None
self._conversation_id_time = None
# Set entity state based on pipeline events
self._run_has_tts = False
assert self.platform.config_entry is not None
self._pipeline_task = self.platform.config_entry.async_create_background_task(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_stream,
pipeline_id=self._resolve_pipeline(),
conversation_id=self._conversation_id,
device_id=device_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
silence_seconds=self._resolve_vad_sensitivity()
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
try:
await self._pipeline_task
finally:
self._pipeline_task = None
with chat_session.async_get_chat_session(
self.hass, self._conversation_id
) as session:
# Store the conversation ID. If it is no longer valid, get_chat_session will reset it
self._conversation_id = session.conversation_id
self._pipeline_task = (
self.platform.config_entry.async_create_background_task(
self.hass,
async_pipeline_from_audio_stream(
self.hass,
context=self._context,
event_callback=self._internal_on_pipeline_event,
stt_metadata=stt.SpeechMetadata(
language="", # set in async_pipeline_from_audio_stream
format=stt.AudioFormats.WAV,
codec=stt.AudioCodecs.PCM,
bit_rate=stt.AudioBitRates.BITRATE_16,
sample_rate=stt.AudioSampleRates.SAMPLERATE_16000,
channel=stt.AudioChannels.CHANNEL_MONO,
),
stt_stream=audio_stream,
pipeline_id=self._resolve_pipeline(),
conversation_id=session.conversation_id,
device_id=device_id,
tts_audio_output=self.tts_options,
wake_word_phrase=wake_word_phrase,
audio_settings=AudioSettings(
silence_seconds=self._resolve_vad_sensitivity()
),
start_stage=start_stage,
end_stage=end_stage,
conversation_extra_system_prompt=extra_system_prompt,
),
f"{self.entity_id}_pipeline",
)
)
try:
await self._pipeline_task
finally:
self._pipeline_task = None
async def _cancel_running_pipeline(self) -> None:
"""Cancel the current pipeline if it's running."""
@ -393,11 +410,6 @@ class AssistSatelliteEntity(entity.Entity):
self._set_state(AssistSatelliteState.LISTENING)
elif event.type is PipelineEventType.INTENT_START:
self._set_state(AssistSatelliteState.PROCESSING)
elif event.type is PipelineEventType.INTENT_END:
assert event.data is not None
# Update timeout
self._conversation_id_time = time.monotonic()
self._conversation_id = event.data["intent_output"]["conversation_id"]
elif event.type is PipelineEventType.TTS_START:
# Wait until tts_response_finished is called to return to waiting state
self._run_has_tts = True

View File

@ -28,5 +28,5 @@
"documentation": "https://www.home-assistant.io/integrations/august",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.6"]
"requirements": ["yalexs==8.10.0", "yalexs-ble==2.5.7"]
}

View File

@ -26,15 +26,18 @@ from .manager import (
BackupReaderWriterError,
CoreBackupReaderWriter,
CreateBackupEvent,
CreateBackupStage,
CreateBackupState,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
WrittenBackup,
)
from .models import AddonInfo, AgentBackup, Folder
from .models import AddonInfo, AgentBackup, BackupNotFound, Folder
from .util import suggested_filename, suggested_filename_from_name_date
from .websocket import async_register_websocket_handlers
@ -45,10 +48,13 @@ __all__ = [
"BackupAgentError",
"BackupAgentPlatformProtocol",
"BackupManagerError",
"BackupNotFound",
"BackupPlatformProtocol",
"BackupReaderWriter",
"BackupReaderWriterError",
"CreateBackupEvent",
"CreateBackupStage",
"CreateBackupState",
"Folder",
"IdleEvent",
"IncorrectPasswordError",
@ -56,6 +62,7 @@ __all__ = [
"ManagerBackup",
"NewBackup",
"RestoreBackupEvent",
"RestoreBackupStage",
"RestoreBackupState",
"WrittenBackup",
"async_get_manager",

View File

@ -11,13 +11,7 @@ from propcache.api import cached_property
from homeassistant.core import HomeAssistant, callback
from .models import AgentBackup, BackupError
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
from .models import AgentBackup, BackupAgentError
class BackupAgentUnreachableError(BackupAgentError):
@ -27,12 +21,6 @@ class BackupAgentUnreachableError(BackupAgentError):
_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):
"""Backup agent interface."""

View File

@ -11,9 +11,9 @@ from typing import Any
from homeassistant.core import HomeAssistant
from homeassistant.helpers.hassio import is_hassio
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
from .agent import BackupAgent, LocalBackupAgent
from .const import DOMAIN, LOGGER
from .models import AgentBackup
from .models import AgentBackup, BackupNotFound
from .util import read_backup, suggested_filename

View File

@ -21,6 +21,7 @@ from . import util
from .agent import BackupAgent
from .const import DATA_MANAGER
from .manager import BackupManager
from .models import BackupNotFound
@callback
@ -69,13 +70,16 @@ class DownloadBackupView(HomeAssistantView):
CONTENT_DISPOSITION: f"attachment; filename={slugify(backup.name)}.tar"
}
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
try:
if not password or not backup.protected:
return await self._send_backup_no_password(
request, headers, backup_id, agent_id, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
return await self._send_backup_with_password(
hass, request, headers, backup_id, agent_id, password, agent, manager
)
except BackupNotFound:
return Response(status=HTTPStatus.NOT_FOUND)
async def _send_backup_no_password(
self,

View File

@ -50,7 +50,14 @@ from .const import (
EXCLUDE_FROM_BACKUP,
LOGGER,
)
from .models import AgentBackup, BackupError, BackupManagerError, BaseBackup, Folder
from .models import (
AgentBackup,
BackupError,
BackupManagerError,
BackupReaderWriterError,
BaseBackup,
Folder,
)
from .store import BackupStore
from .util import (
AsyncIteratorReader,
@ -274,12 +281,6 @@ class BackupReaderWriter(abc.ABC):
"""Get restore events after core restart."""
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class IncorrectPasswordError(BackupReaderWriterError):
"""Raised when the password is incorrect."""

View File

@ -41,12 +41,6 @@ class BaseBackup:
homeassistant_version: str | None # None if homeassistant_included is False
name: str
def as_frontend_json(self) -> dict:
"""Return a dict representation of this backup for sending to frontend."""
return {
key: val for key, val in asdict(self).items() if key != "extra_metadata"
}
@dataclass(frozen=True, kw_only=True)
class AgentBackup(BaseBackup):
@ -83,7 +77,25 @@ class BackupError(HomeAssistantError):
error_code = "unknown"
class BackupAgentError(BackupError):
"""Base class for backup agent errors."""
error_code = "backup_agent_error"
class BackupManagerError(BackupError):
"""Backup manager error."""
error_code = "backup_manager_error"
class BackupReaderWriterError(BackupError):
"""Backup reader/writer error."""
error_code = "backup_reader_writer_error"
class BackupNotFound(BackupAgentError, BackupManagerError):
"""Raised when a backup is not found."""
error_code = "backup_not_found"

View File

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator, Callable, Coroutine
from concurrent.futures import CancelledError, Future
import copy
from dataclasses import dataclass, replace
from io import BytesIO
@ -12,6 +13,7 @@ import os
from pathlib import Path, PurePath
from queue import SimpleQueue
import tarfile
import threading
from typing import IO, Any, Self, cast
import aiohttp
@ -22,7 +24,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.util import dt as dt_util
from homeassistant.util.json import JsonObjectType, json_loads_object
from homeassistant.util.thread import ThreadWithException
from .const import BUF_SIZE, LOGGER
from .models import AddonInfo, AgentBackup, Folder
@ -167,23 +168,38 @@ class AsyncIteratorReader:
def __init__(self, hass: HomeAssistant, stream: AsyncIterator[bytes]) -> None:
"""Initialize the wrapper."""
self._aborted = False
self._hass = hass
self._stream = stream
self._buffer: bytes | None = None
self._next_future: Future[bytes | None] | None = None
self._pos: int = 0
async def _next(self) -> bytes | None:
"""Get the next chunk from the iterator."""
return await anext(self._stream, None)
def abort(self) -> None:
"""Abort the reader."""
self._aborted = True
if self._next_future is not None:
self._next_future.cancel()
def read(self, n: int = -1, /) -> bytes:
"""Read data from the iterator."""
result = bytearray()
while n < 0 or len(result) < n:
if not self._buffer:
self._buffer = asyncio.run_coroutine_threadsafe(
self._next_future = asyncio.run_coroutine_threadsafe(
self._next(), self._hass.loop
).result()
)
if self._aborted:
self._next_future.cancel()
raise AbortCipher
try:
self._buffer = self._next_future.result()
except CancelledError as err:
raise AbortCipher from err
self._pos = 0
if not self._buffer:
# The stream is exhausted
@ -205,9 +221,11 @@ class AsyncIteratorWriter:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the wrapper."""
self._aborted = False
self._hass = hass
self._pos: int = 0
self._queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=1)
self._write_future: Future[bytes | None] | None = None
def __aiter__(self) -> Self:
"""Return the iterator."""
@ -219,13 +237,28 @@ class AsyncIteratorWriter:
return data
raise StopAsyncIteration
def abort(self) -> None:
"""Abort the writer."""
self._aborted = True
if self._write_future is not None:
self._write_future.cancel()
def tell(self) -> int:
"""Return the current position in the iterator."""
return self._pos
def write(self, s: bytes, /) -> int:
"""Write data to the iterator."""
asyncio.run_coroutine_threadsafe(self._queue.put(s), self._hass.loop).result()
self._write_future = asyncio.run_coroutine_threadsafe(
self._queue.put(s), self._hass.loop
)
if self._aborted:
self._write_future.cancel()
raise AbortCipher
try:
self._write_future.result()
except CancelledError as err:
raise AbortCipher from err
self._pos += len(s)
return len(s)
@ -415,7 +448,9 @@ def _encrypt_backup(
class _CipherWorkerStatus:
done: asyncio.Event
error: Exception | None = None
thread: ThreadWithException
reader: AsyncIteratorReader
thread: threading.Thread
writer: AsyncIteratorWriter
class _CipherBackupStreamer:
@ -468,11 +503,13 @@ class _CipherBackupStreamer:
stream = await self._open_stream()
reader = AsyncIteratorReader(self._hass, stream)
writer = AsyncIteratorWriter(self._hass)
worker = ThreadWithException(
worker = threading.Thread(
target=self._cipher_func,
args=[reader, writer, self._password, on_done, self.size(), self._nonces],
)
worker_status = _CipherWorkerStatus(done=asyncio.Event(), thread=worker)
worker_status = _CipherWorkerStatus(
done=asyncio.Event(), reader=reader, thread=worker, writer=writer
)
self._workers.append(worker_status)
worker.start()
return writer
@ -480,9 +517,8 @@ class _CipherBackupStreamer:
async def wait(self) -> None:
"""Wait for the worker threads to finish."""
for worker in self._workers:
if not worker.thread.is_alive():
continue
worker.thread.raise_exc(AbortCipher)
worker.reader.abort()
worker.writer.abort()
await asyncio.gather(*(worker.done.wait() for worker in self._workers))

View File

@ -15,7 +15,7 @@ from .manager import (
IncorrectPasswordError,
ManagerStateEvent,
)
from .models import Folder
from .models import BackupNotFound, Folder
@callback
@ -57,7 +57,7 @@ async def handle_info(
"agent_errors": {
agent_id: str(err) for agent_id, err in agent_errors.items()
},
"backups": [backup.as_frontend_json() for backup in backups.values()],
"backups": list(backups.values()),
"last_attempted_automatic_backup": manager.config.data.last_attempted_automatic_backup,
"last_completed_automatic_backup": manager.config.data.last_completed_automatic_backup,
"last_non_idle_event": manager.last_non_idle_event,
@ -91,7 +91,7 @@ async def handle_details(
"agent_errors": {
agent_id: str(err) for agent_id, err in agent_errors.items()
},
"backup": backup.as_frontend_json() if backup else None,
"backup": backup,
},
)
@ -151,6 +151,8 @@ async def handle_restore(
restore_folders=msg.get("restore_folders"),
restore_homeassistant=msg["restore_homeassistant"],
)
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
else:
@ -179,6 +181,8 @@ async def handle_can_decrypt_on_download(
agent_id=msg["agent_id"],
password=msg.get("password"),
)
except BackupNotFound:
connection.send_error(msg["id"], "backup_not_found", "Backup not found")
except IncorrectPasswordError:
connection.send_error(msg["id"], "password_incorrect", "Incorrect password")
except DecryptOnDowloadNotSupported:

View File

@ -19,6 +19,8 @@ from .const import (
)
from .entity import BangOlufsenEntity
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,

View File

@ -28,7 +28,7 @@
"services": {
"join": {
"name": "Join",
"description": "Group player together.",
"description": "Groups players together under a single master speaker.",
"fields": {
"master": {
"name": "Master",
@ -36,23 +36,23 @@
},
"entity_id": {
"name": "Entity",
"description": "Name of entity that will coordinate the grouping. Platform dependent."
"description": "Name of entity that will group to master speaker. Platform dependent."
}
}
},
"unjoin": {
"name": "Unjoin",
"description": "Unjoin the player from a group.",
"description": "Separates a player from a group.",
"fields": {
"entity_id": {
"name": "Entity",
"description": "Name of entity that will be unjoined from their group. Platform dependent."
"description": "Name of entity that will be separated from their group. Platform dependent."
}
}
},
"set_sleep_timer": {
"name": "Set sleep timer",
"description": "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0.",
"description": "Sets a Bluesound timer that will turn off the speaker. It will increase in steps: 15, 30, 45, 60, 90, 0.",
"fields": {
"entity_id": {
"name": "Entity",
@ -62,7 +62,7 @@
},
"clear_sleep_timer": {
"name": "Clear sleep timer",
"description": "Clear a Bluesound timer.",
"description": "Clears a Bluesound timer.",
"fields": {
"entity_id": {
"name": "Entity",

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import datetime
import logging
import platform
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any
from bleak_retry_connector import BleakSlotManager
from bluetooth_adapters import (
@ -80,6 +80,7 @@ from .const import (
CONF_DETAILS,
CONF_PASSIVE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -297,7 +298,11 @@ async def async_discover_adapters(
async def async_update_device(
hass: HomeAssistant, entry: ConfigEntry, adapter: str, details: AdapterDetails
hass: HomeAssistant,
entry: ConfigEntry,
adapter: str,
details: AdapterDetails,
via_device_id: str | None = None,
) -> None:
"""Update device registry entry.
@ -306,7 +311,8 @@ async def async_update_device(
update the device with the new location so they can
figure out where the adapter is.
"""
dr.async_get(hass).async_get_or_create(
device_registry = dr.async_get(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
name=adapter_human_name(adapter, details[ADAPTER_ADDRESS]),
connections={(dr.CONNECTION_BLUETOOTH, details[ADAPTER_ADDRESS])},
@ -315,6 +321,11 @@ async def async_update_device(
sw_version=details.get(ADAPTER_SW_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:
@ -349,6 +360,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
entry,
source_entry.title,
details,
entry.data.get(CONF_SOURCE_DEVICE_ID),
)
return True
manager = _get_manager(hass)

View File

@ -181,10 +181,16 @@ def async_register_scanner(
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a BleakScanner."""
return _get_manager(hass).async_register_hass_scanner(
scanner, connection_slots, source_domain, source_model, source_config_entry_id
scanner,
connection_slots,
source_domain,
source_model,
source_config_entry_id,
source_device_id,
)

View File

@ -37,6 +37,7 @@ from .const import (
CONF_PASSIVE,
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -139,7 +140,7 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
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()
await bluetooth_adapters.refresh()
self._adapters = bluetooth_adapters.adapters
@ -154,12 +155,8 @@ class BluetoothConfigFlow(ConfigFlow, domain=DOMAIN):
and not (system == "Linux" and details[ADAPTER_ADDRESS] == DEFAULT_ADDRESS)
]
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(
reason="no_adapters",
description_placeholders={"ignored_adapters": str(ignored_adapters)},
)
if len(unconfigured_adapters) == 1:
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_DOMAIN: user_input[CONF_SOURCE_DOMAIN],
CONF_SOURCE_CONFIG_ENTRY_ID: user_input[CONF_SOURCE_CONFIG_ENTRY_ID],
CONF_SOURCE_DEVICE_ID: user_input[CONF_SOURCE_DEVICE_ID],
}
self._abort_if_unique_id_configured(updates=data)
manager = get_manager()

View File

@ -22,7 +22,7 @@ CONF_SOURCE: Final = "source"
CONF_SOURCE_DOMAIN: Final = "source_domain"
CONF_SOURCE_MODEL: Final = "source_model"
CONF_SOURCE_CONFIG_ENTRY_ID: Final = "source_config_entry_id"
CONF_SOURCE_DEVICE_ID: Final = "source_device_id"
SOURCE_LOCAL: Final = "local"

View File

@ -25,6 +25,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
CONF_SOURCE,
CONF_SOURCE_CONFIG_ENTRY_ID,
CONF_SOURCE_DEVICE_ID,
CONF_SOURCE_DOMAIN,
CONF_SOURCE_MODEL,
DOMAIN,
@ -254,6 +255,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
source_domain: str | None = None,
source_model: str | None = None,
source_config_entry_id: str | None = None,
source_device_id: str | None = None,
) -> CALLBACK_TYPE:
"""Register a scanner."""
cancel = self.async_register_scanner(scanner, connection_slots)
@ -261,9 +263,6 @@ class HomeAssistantBluetoothManager(BluetoothManager):
isinstance(scanner, BaseHaRemoteScanner)
and source_domain
and source_config_entry_id
and not self.hass.config_entries.async_entry_for_domain_unique_id(
DOMAIN, scanner.source
)
):
self.hass.async_create_task(
self.hass.config_entries.flow.async_init(
@ -274,6 +273,7 @@ class HomeAssistantBluetoothManager(BluetoothManager):
CONF_SOURCE_DOMAIN: source_domain,
CONF_SOURCE_MODEL: source_model,
CONF_SOURCE_CONFIG_ENTRY_ID: source_config_entry_id,
CONF_SOURCE_DEVICE_ID: source_device_id,
},
)
)

View File

@ -16,11 +16,11 @@
"quality_scale": "internal",
"requirements": [
"bleak==0.22.3",
"bleak-retry-connector==3.8.0",
"bluetooth-adapters==0.21.1",
"bleak-retry-connector==3.8.1",
"bluetooth-adapters==0.21.4",
"bluetooth-auto-recovery==1.4.2",
"bluetooth-data-tools==1.22.0",
"dbus-fast==2.30.2",
"habluetooth==3.20.1"
"bluetooth-data-tools==1.23.4",
"dbus-fast==2.32.0",
"habluetooth==3.21.1"
]
}

View File

@ -23,7 +23,7 @@
},
"abort": {
"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": {

View File

@ -132,7 +132,7 @@ class BTHomeConfigFlow(ConfigFlow, domain=DOMAIN):
return self._async_get_or_create_entry()
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@ -20,5 +20,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/bthome",
"iot_class": "local_push",
"requirements": ["bthome-ble==3.12.3"]
"requirements": ["bthome-ble==3.12.4"]
}

View File

@ -67,6 +67,11 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
),
# Channel (-)
(BTHomeExtendedSensorDeviceClass.CHANNEL, None): SensorEntityDescription(
key=str(BTHomeExtendedSensorDeviceClass.CHANNEL),
state_class=SensorStateClass.MEASUREMENT,
),
# Conductivity (µS/cm)
(
BTHomeSensorDeviceClass.CONDUCTIVITY,

View File

@ -29,6 +29,7 @@ from homeassistant.components.google_assistant import helpers as google_helpers
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.http import KEY_HASS, HomeAssistantView, require_admin
from homeassistant.components.http.data_validator import RequestDataValidator
from homeassistant.components.system_health import get_info as get_system_health_info
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@ -107,6 +108,7 @@ def async_setup(hass: HomeAssistant) -> None:
hass.http.register_view(CloudRegisterView)
hass.http.register_view(CloudResendConfirmView)
hass.http.register_view(CloudForgotPasswordView)
hass.http.register_view(DownloadSupportPackageView)
_CLOUD_ERRORS.update(
{
@ -389,6 +391,59 @@ class CloudForgotPasswordView(HomeAssistantView):
return self.json_message("ok")
class DownloadSupportPackageView(HomeAssistantView):
"""Download support package view."""
url = "/api/cloud/support_package"
name = "api:cloud:support_package"
def _generate_markdown(
self, hass_info: dict[str, Any], domains_info: dict[str, dict[str, str]]
) -> str:
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
return "No information available\n"
markdown = ""
first = True
for key, value in domain_info.items():
markdown += f"{key} | {value}\n"
if first:
markdown += "--- | ---\n"
first = False
return markdown + "\n"
markdown = "## System Information\n\n"
markdown += get_domain_table_markdown(hass_info)
for domain, domain_info in domains_info.items():
domain_info_md = get_domain_table_markdown(domain_info)
markdown += (
f"<details><summary>{domain}</summary>\n\n"
f"{domain_info_md}"
"</details>\n\n"
)
return markdown
async def get(self, request: web.Request) -> web.Response:
"""Download support package file."""
hass = request.app[KEY_HASS]
domain_health = await get_system_health_info(hass)
hass_info = domain_health.pop("homeassistant", {})
markdown = self._generate_markdown(hass_info, domain_health)
return web.Response(
body=markdown,
content_type="text/markdown",
headers={
"Content-Disposition": 'attachment; filename="support_package.md"'
},
)
@websocket_api.require_admin
@websocket_api.websocket_command({vol.Required("type"): "cloud/remove_data"})
@websocket_api.async_response

View File

@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["hass_nabucasa"],
"requirements": ["hass-nabucasa==0.88.1"],
"requirements": ["hass-nabucasa==0.89.0"],
"single_config_entry": true
}

View File

@ -38,6 +38,156 @@ ATTR_GENDER = "gender"
DEPRECATED_VOICES = {"XiaoxuanNeural": "XiaozhenNeural"}
SUPPORT_LANGUAGES = list(TTS_VOICES)
DEFAULT_VOICES = {
"af-ZA": "AdriNeural",
"am-ET": "MekdesNeural",
"ar-AE": "FatimaNeural",
"ar-BH": "LailaNeural",
"ar-DZ": "AminaNeural",
"ar-EG": "SalmaNeural",
"ar-IQ": "RanaNeural",
"ar-JO": "SanaNeural",
"ar-KW": "NouraNeural",
"ar-LB": "LaylaNeural",
"ar-LY": "ImanNeural",
"ar-MA": "MounaNeural",
"ar-OM": "AbdullahNeural",
"ar-QA": "AmalNeural",
"ar-SA": "ZariyahNeural",
"ar-SY": "AmanyNeural",
"ar-TN": "ReemNeural",
"ar-YE": "MaryamNeural",
"az-AZ": "BabekNeural",
"bg-BG": "KalinaNeural",
"bn-BD": "NabanitaNeural",
"bn-IN": "TanishaaNeural",
"bs-BA": "GoranNeural",
"ca-ES": "JoanaNeural",
"cs-CZ": "VlastaNeural",
"cy-GB": "NiaNeural",
"da-DK": "ChristelNeural",
"de-AT": "IngridNeural",
"de-CH": "LeniNeural",
"de-DE": "KatjaNeural",
"el-GR": "AthinaNeural",
"en-AU": "NatashaNeural",
"en-CA": "ClaraNeural",
"en-GB": "LibbyNeural",
"en-HK": "YanNeural",
"en-IE": "EmilyNeural",
"en-IN": "NeerjaNeural",
"en-KE": "AsiliaNeural",
"en-NG": "EzinneNeural",
"en-NZ": "MollyNeural",
"en-PH": "RosaNeural",
"en-SG": "LunaNeural",
"en-TZ": "ImaniNeural",
"en-US": "JennyNeural",
"en-ZA": "LeahNeural",
"es-AR": "ElenaNeural",
"es-BO": "SofiaNeural",
"es-CL": "CatalinaNeural",
"es-CO": "SalomeNeural",
"es-CR": "MariaNeural",
"es-CU": "BelkysNeural",
"es-DO": "RamonaNeural",
"es-EC": "AndreaNeural",
"es-ES": "ElviraNeural",
"es-GQ": "TeresaNeural",
"es-GT": "MartaNeural",
"es-HN": "KarlaNeural",
"es-MX": "DaliaNeural",
"es-NI": "YolandaNeural",
"es-PA": "MargaritaNeural",
"es-PE": "CamilaNeural",
"es-PR": "KarinaNeural",
"es-PY": "TaniaNeural",
"es-SV": "LorenaNeural",
"es-US": "PalomaNeural",
"es-UY": "ValentinaNeural",
"es-VE": "PaolaNeural",
"et-EE": "AnuNeural",
"eu-ES": "AinhoaNeural",
"fa-IR": "DilaraNeural",
"fi-FI": "SelmaNeural",
"fil-PH": "BlessicaNeural",
"fr-BE": "CharlineNeural",
"fr-CA": "SylvieNeural",
"fr-CH": "ArianeNeural",
"fr-FR": "DeniseNeural",
"ga-IE": "OrlaNeural",
"gl-ES": "SabelaNeural",
"gu-IN": "DhwaniNeural",
"he-IL": "HilaNeural",
"hi-IN": "SwaraNeural",
"hr-HR": "GabrijelaNeural",
"hu-HU": "NoemiNeural",
"hy-AM": "AnahitNeural",
"id-ID": "GadisNeural",
"is-IS": "GudrunNeural",
"it-IT": "ElsaNeural",
"ja-JP": "NanamiNeural",
"jv-ID": "SitiNeural",
"ka-GE": "EkaNeural",
"kk-KZ": "AigulNeural",
"km-KH": "SreymomNeural",
"kn-IN": "SapnaNeural",
"ko-KR": "SunHiNeural",
"lo-LA": "KeomanyNeural",
"lt-LT": "OnaNeural",
"lv-LV": "EveritaNeural",
"mk-MK": "MarijaNeural",
"ml-IN": "SobhanaNeural",
"mn-MN": "BataaNeural",
"mr-IN": "AarohiNeural",
"ms-MY": "YasminNeural",
"mt-MT": "GraceNeural",
"my-MM": "NilarNeural",
"nb-NO": "IselinNeural",
"ne-NP": "HemkalaNeural",
"nl-BE": "DenaNeural",
"nl-NL": "ColetteNeural",
"pl-PL": "AgnieszkaNeural",
"ps-AF": "LatifaNeural",
"pt-BR": "FranciscaNeural",
"pt-PT": "RaquelNeural",
"ro-RO": "AlinaNeural",
"ru-RU": "SvetlanaNeural",
"si-LK": "ThiliniNeural",
"sk-SK": "ViktoriaNeural",
"sl-SI": "PetraNeural",
"so-SO": "UbaxNeural",
"sq-AL": "AnilaNeural",
"sr-RS": "SophieNeural",
"su-ID": "TutiNeural",
"sv-SE": "SofieNeural",
"sw-KE": "ZuriNeural",
"sw-TZ": "RehemaNeural",
"ta-IN": "PallaviNeural",
"ta-LK": "SaranyaNeural",
"ta-MY": "KaniNeural",
"ta-SG": "VenbaNeural",
"te-IN": "ShrutiNeural",
"th-TH": "AcharaNeural",
"tr-TR": "EmelNeural",
"uk-UA": "PolinaNeural",
"ur-IN": "GulNeural",
"ur-PK": "UzmaNeural",
"uz-UZ": "MadinaNeural",
"vi-VN": "HoaiMyNeural",
"wuu-CN": "XiaotongNeural",
"yue-CN": "XiaoMinNeural",
"zh-CN": "XiaoxiaoNeural",
"zh-CN-henan": "YundengNeural",
"zh-CN-liaoning": "XiaobeiNeural",
"zh-CN-shaanxi": "XiaoniNeural",
"zh-CN-shandong": "YunxiangNeural",
"zh-CN-sichuan": "YunxiNeural",
"zh-HK": "HiuMaanNeural",
"zh-TW": "HsiaoChenNeural",
"zu-ZA": "ThandoNeural",
}
_LOGGER = logging.getLogger(__name__)
@ -186,12 +336,13 @@ class CloudTTSEntity(TextToSpeechEntity):
"""Load TTS from Home Assistant Cloud."""
gender: Gender | str | None = options.get(ATTR_GENDER)
gender = handle_deprecated_gender(self.hass, gender)
original_voice: str | None = options.get(ATTR_VOICE)
if original_voice is None and language == self._language:
original_voice = self._voice
original_voice: str = options.get(
ATTR_VOICE,
self._voice if language == self._language else DEFAULT_VOICES[language],
)
voice = handle_deprecated_voice(self.hass, original_voice)
if voice not in TTS_VOICES[language]:
default_voice = TTS_VOICES[language][0]
default_voice = DEFAULT_VOICES[language]
_LOGGER.debug(
"Unsupported voice %s detected, falling back to default %s for %s",
voice,
@ -266,12 +417,13 @@ class CloudProvider(Provider):
assert self.hass is not None
gender: Gender | str | None = options.get(ATTR_GENDER)
gender = handle_deprecated_gender(self.hass, gender)
original_voice: str | None = options.get(ATTR_VOICE)
if original_voice is None and language == self._language:
original_voice = self._voice
original_voice: str = options.get(
ATTR_VOICE,
self._voice if language == self._language else DEFAULT_VOICES[language],
)
voice = handle_deprecated_voice(self.hass, original_voice)
if voice not in TTS_VOICES[language]:
default_voice = TTS_VOICES[language][0]
default_voice = DEFAULT_VOICES[language]
_LOGGER.debug(
"Unsupported voice %s detected, falling back to default %s for %s",
voice,

View File

@ -302,7 +302,8 @@ def config_entries_progress(
[
flw
for flw in hass.config_entries.flow.async_progress()
if flw["context"]["source"] != config_entries.SOURCE_USER
if flw["context"]["source"]
not in (config_entries.SOURCE_RECONFIGURE, config_entries.SOURCE_USER)
],
)

View File

@ -30,6 +30,16 @@ from .agent_manager import (
async_get_agent,
get_agent_manager,
)
from .chat_log import (
AssistantContent,
ChatLog,
Content,
ConverseError,
SystemContent,
ToolResultContent,
UserContent,
async_get_chat_log,
)
from .const import (
ATTR_AGENT_ID,
ATTR_CONVERSATION_ID,
@ -48,20 +58,14 @@ from .default_agent import DefaultAgent, async_setup_default_agent
from .entity import ConversationEntity
from .http import async_setup as async_setup_conversation_http
from .models import AbstractConversationAgent, ConversationInput, ConversationResult
from .session import (
ChatSession,
Content,
ConverseError,
NativeContent,
async_get_chat_session,
)
from .trace import ConversationTraceEventType, async_conversation_trace_append
__all__ = [
"DOMAIN",
"HOME_ASSISTANT_AGENT",
"OLD_HOME_ASSISTANT_AGENT",
"ChatSession",
"AssistantContent",
"ChatLog",
"Content",
"ConversationEntity",
"ConversationEntityFeature",
@ -69,11 +73,13 @@ __all__ = [
"ConversationResult",
"ConversationTraceEventType",
"ConverseError",
"NativeContent",
"SystemContent",
"ToolResultContent",
"UserContent",
"async_conversation_trace_append",
"async_converse",
"async_get_agent_info",
"async_get_chat_session",
"async_get_chat_log",
"async_set_agent",
"async_setup",
"async_unset_agent",

View 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,
},
)

View File

@ -42,6 +42,7 @@ from homeassistant.components.homeassistant.exposed_entities import (
from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
from homeassistant.helpers import (
area_registry as ar,
chat_session,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
@ -54,6 +55,7 @@ from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.event import async_track_state_added_domain
from homeassistant.util.json import JsonObjectType, json_loads_object
from .chat_log import AssistantContent, async_get_chat_log
from .const import (
DATA_DEFAULT_ENTITY,
DEFAULT_EXPOSED_ATTRIBUTES,
@ -62,7 +64,6 @@ from .const import (
)
from .entity import ConversationEntity
from .models import ConversationInput, ConversationResult
from .session import Content, async_get_chat_session
from .trace import ConversationTraceEventType, async_conversation_trace_append
_LOGGER = logging.getLogger(__name__)
@ -348,7 +349,12 @@ class DefaultAgent(ConversationEntity):
async def async_process(self, user_input: ConversationInput) -> ConversationResult:
"""Process a sentence."""
response: intent.IntentResponse | None = None
async with async_get_chat_session(self.hass, user_input) as chat_session:
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
async_get_chat_log(self.hass, session, user_input) as chat_log,
):
# Check if a trigger matched
if trigger_result := await self.async_recognize_sentence_trigger(
user_input
@ -373,16 +379,15 @@ class DefaultAgent(ConversationEntity):
)
speech: str = response.speech.get("plain", {}).get("speech", "")
chat_session.async_add_message(
Content(
role="assistant",
agent_id=user_input.agent_id,
chat_log.async_add_assistant_content_without_tools(
AssistantContent(
agent_id=user_input.agent_id, # type: ignore[arg-type]
content=speech,
)
)
return ConversationResult(
response=response, conversation_id=chat_session.conversation_id
response=response, conversation_id=session.conversation_id
)
async def _async_process_intent_result(

View File

@ -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

View File

@ -14,8 +14,8 @@
],
"quality_scale": "internal",
"requirements": [
"aiodhcpwatcher==1.0.2",
"aiodiscover==2.1.0",
"aiodhcpwatcher==1.1.0",
"aiodiscover==2.2.2",
"cached-ipaddress==0.8.0"
]
}

View File

@ -3,7 +3,7 @@
Data is fetched from DWD:
https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor
Warnungen vor extremem Unwetter (Stufe 4) # codespell:ignore vor,extremem
Unwetterwarnungen (Stufe 3)
Warnungen vor markantem Wetter (Stufe 2) # codespell:ignore vor
Wetterwarnungen (Stufe 1)

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/econet",
"iot_class": "cloud_push",
"loggers": ["paho_mqtt", "pyeconet"],
"requirements": ["pyeconet==0.1.23"]
"requirements": ["pyeconet==0.1.26"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.10", "deebot-client==11.1.0b2"]
"requirements": ["py-sucks==0.9.10", "deebot-client==12.0.0"]
}

View File

@ -22,5 +22,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eq3btsmart"],
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.6.0"]
"requirements": ["eq3btsmart==1.4.1", "bleak-esphome==2.7.1"]
}

View File

@ -28,6 +28,7 @@ def async_connect_scanner(
entry_data: RuntimeEntryData,
cli: APIClient,
device_info: DeviceInfo,
device_id: str,
) -> CALLBACK_TYPE:
"""Connect scanner."""
client_data = connect_scanner(cli, device_info, entry_data.available)
@ -45,6 +46,7 @@ def async_connect_scanner(
source_domain=DOMAIN,
source_model=device_info.model,
source_config_entry_id=entry_data.entry_id,
source_device_id=device_id,
),
scanner.async_setup(),
],

View File

@ -425,7 +425,9 @@ class ESPHomeManager:
if device_info.bluetooth_proxy_feature_flags_compat(api_version):
entry_data.disconnect_callbacks.add(
async_connect_scanner(hass, entry_data, cli, device_info)
async_connect_scanner(
hass, entry_data, cli, device_info, self.device_id
)
)
else:
bluetooth.async_remove_scanner(hass, device_info.mac_address)
@ -571,7 +573,9 @@ def _async_setup_device_registry(
configuration_url = None
if device_info.webserver_port > 0:
configuration_url = f"http://{entry.data['host']}:{device_info.webserver_port}"
entry_host = entry.data["host"]
host = f"[{entry_host}]" if ":" in entry_host else entry_host
configuration_url = f"http://{host}:{device_info.webserver_port}"
elif (
(dashboard := async_get_dashboard(hass))
and dashboard.data

View File

@ -18,7 +18,7 @@
"requirements": [
"aioesphomeapi==29.0.0",
"esphome-dashboard-api==1.2.3",
"bleak-esphome==2.6.0"
"bleak-esphome==2.7.1"
],
"zeroconf": ["_esphomelib._tcp.local."]
}

View File

@ -1,33 +1,27 @@
"""The FAA Delays integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ID, Platform
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import FAADataUpdateCoordinator
from .coordinator import FAAConfigEntry, FAADataUpdateCoordinator
PLATFORMS = [Platform.BINARY_SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
"""Set up FAA Delays from a config entry."""
code = entry.data[CONF_ID]
coordinator = FAADataUpdateCoordinator(hass, code)
coordinator = FAADataUpdateCoordinator(hass, entry, code)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: FAAConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -12,13 +12,12 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FAADataUpdateCoordinator
from . import FAAConfigEntry, FAADataUpdateCoordinator
from .const import DOMAIN
@ -84,10 +83,10 @@ FAA_BINARY_SENSORS: tuple[FaaDelaysBinarySensorEntityDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
hass: HomeAssistant, entry: FAAConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up a FAA sensor based on a config entry."""
coordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
entities = [
FAABinarySensor(coordinator, entry.entry_id, description)

View File

@ -7,6 +7,7 @@ import logging
from aiohttp import ClientConnectionError
from faadelays import Airport
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@ -15,14 +16,20 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type FAAConfigEntry = ConfigEntry[FAADataUpdateCoordinator]
class FAADataUpdateCoordinator(DataUpdateCoordinator[Airport]):
"""Class to manage fetching FAA API data from a single endpoint."""
def __init__(self, hass: HomeAssistant, code: str) -> None:
def __init__(self, hass: HomeAssistant, entry: FAAConfigEntry, code: str) -> None:
"""Initialize the coordinator."""
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)
hass,
_LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(minutes=1),
)
self.session = aiohttp_client.async_get_clientsession(hass)
self.data = Airport(code, self.session)

View File

@ -4,20 +4,20 @@ from __future__ import annotations
import logging
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.start import async_at_started
from .const import DOMAIN, PLATFORMS
from .coordinator import FastdotcomDataUpdateCoordinator
from .const import PLATFORMS
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool:
"""Set up Fast.com from a config entry."""
coordinator = FastdotcomDataUpdateCoordinator(hass)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
coordinator = FastdotcomDataUpdateCoordinator(hass, entry)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(
entry,
@ -36,8 +36,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: FastdotcomConfigEntry) -> bool:
"""Unload Fast.com config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@ -6,20 +6,24 @@ from datetime import timedelta
from fastdotcom import fast_com
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_INTERVAL, DOMAIN, LOGGER
type FastdotcomConfigEntry = ConfigEntry[FastdotcomDataUpdateCoordinator]
class FastdotcomDataUpdateCoordinator(DataUpdateCoordinator[float]):
"""Class to manage fetching Fast.com data API."""
def __init__(self, hass: HomeAssistant) -> None:
def __init__(self, hass: HomeAssistant, entry: FastdotcomConfigEntry) -> None:
"""Initialize the coordinator for Fast.com."""
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=DOMAIN,
update_interval=timedelta(hours=DEFAULT_INTERVAL),
)

View File

@ -4,21 +4,13 @@ from __future__ import annotations
from typing import Any
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .coordinator import FastdotcomDataUpdateCoordinator
from .coordinator import FastdotcomConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: FastdotcomConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for the config entry."""
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][
config_entry.entry_id
]
return {
"coordinator_data": coordinator.data,
}
return {"coordinator_data": config_entry.runtime_data.data}

View File

@ -7,7 +7,6 @@ from homeassistant.components.sensor import (
SensorEntity,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfDataRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
@ -15,17 +14,16 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import FastdotcomDataUpdateCoordinator
from .coordinator import FastdotcomConfigEntry, FastdotcomDataUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: FastdotcomConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Fast.com sensor."""
coordinator: FastdotcomDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([SpeedtestSensor(entry.entry_id, coordinator)])
async_add_entities([SpeedtestSensor(entry.entry_id, entry.runtime_data)])
class SpeedtestSensor(CoordinatorEntity[FastdotcomDataUpdateCoordinator], SensorEntity):

View File

@ -21,9 +21,11 @@ from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import raise_if_invalid_filename
from homeassistant.util.hass_dict import HassKey
from homeassistant.util.ulid import ulid_hex
DOMAIN = "file_upload"
_DATA: HassKey[FileUploadData] = HassKey(DOMAIN)
ONE_MEGABYTE = 1024 * 1024
MAX_SIZE = 100 * ONE_MEGABYTE
@ -41,7 +43,7 @@ def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]:
if DOMAIN not in hass.data:
raise ValueError("File does not exist")
file_upload_data: FileUploadData = hass.data[DOMAIN]
file_upload_data = hass.data[_DATA]
if not file_upload_data.has_file(file_id):
raise ValueError("File does not exist")
@ -149,10 +151,10 @@ class FileUploadView(HomeAssistantView):
hass = request.app[KEY_HASS]
file_id = ulid_hex()
if DOMAIN not in hass.data:
hass.data[DOMAIN] = await FileUploadData.create(hass)
if _DATA not in hass.data:
hass.data[_DATA] = await FileUploadData.create(hass)
file_upload_data: FileUploadData = hass.data[DOMAIN]
file_upload_data = hass.data[_DATA]
file_dir = file_upload_data.file_dir(file_id)
queue: SimpleQueue[tuple[bytes, asyncio.Future[None] | None] | None] = (
SimpleQueue()
@ -206,7 +208,7 @@ class FileUploadView(HomeAssistantView):
raise web.HTTPNotFound
file_id = data["file_id"]
file_upload_data: FileUploadData = hass.data[DOMAIN]
file_upload_data = hass.data[_DATA]
if file_upload_data.files.pop(file_id, None) is None:
raise web.HTTPNotFound

View File

@ -21,5 +21,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20250131.0"]
"requirements": ["home-assistant-frontend==20250204.0"]
}

View File

@ -244,7 +244,7 @@ class AFSAPIDevice(MediaPlayerEntity):
"""Send volume up command."""
volume = await self.fs_device.get_volume()
volume = int(volume or 0) + 1
await self.fs_device.set_volume(min(volume, self._max_volume))
await self.fs_device.set_volume(min(volume, self._max_volume or 1))
async def async_volume_down(self) -> None:
"""Send volume down command."""

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from typing import TYPE_CHECKING, Any
from homeassistant.components.sensor import (
SensorDeviceClass,
@ -152,6 +152,8 @@ class FullySensor(FullyKioskEntity, SensorEntity):
value, extra_state_attributes = self.entity_description.state_fn(value)
if self.entity_description.round_state_value:
if TYPE_CHECKING:
assert isinstance(value, int)
value = round_storage(value)
self._attr_native_value = value

View File

@ -28,14 +28,14 @@
"user": {
"description": "Enter the settings to connect to the camera.",
"data": {
"still_image_url": "Still Image URL (e.g. http://...)",
"stream_source": "Stream Source URL (e.g. rtsp://...)",
"still_image_url": "Still image URL (e.g. http://...)",
"stream_source": "Stream source URL (e.g. rtsp://...)",
"rtsp_transport": "RTSP transport protocol",
"authentication": "Authentication",
"limit_refetch_to_url_change": "Limit refetch to url change",
"limit_refetch_to_url_change": "Limit refetch to URL change",
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]",
"framerate": "Frame Rate (Hz)",
"framerate": "Frame rate (Hz)",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
}
},

View File

@ -4,7 +4,7 @@ from __future__ import annotations
import codecs
from collections.abc import Callable
from typing import Any, Literal
from typing import Any, Literal, cast
from google.api_core.exceptions import GoogleAPIError
import google.generativeai as genai
@ -18,7 +18,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LLM_HASS_API, MATCH_ALL
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr, intent, llm
from homeassistant.helpers import chat_session, device_registry as dr, intent, llm
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import (
@ -149,15 +149,53 @@ def _escape_decode(value: Any) -> Any:
return value
def _chat_message_convert(
message: conversation.Content | conversation.NativeContent[genai_types.ContentDict],
) -> genai_types.ContentDict:
"""Convert any native chat message for this agent to the native format."""
if message.role == "native":
return message.content
def _create_google_tool_response_content(
content: list[conversation.ToolResultContent],
) -> protos.Content:
"""Create a Google tool response content."""
return protos.Content(
parts=[
protos.Part(
function_response=protos.FunctionResponse(
name=tool_result.tool_name, response=tool_result.tool_result
)
)
for tool_result in content
]
)
role = "model" if message.role == "assistant" else message.role
return {"role": role, "parts": message.content}
def _convert_content(
content: conversation.UserContent
| conversation.AssistantContent
| conversation.SystemContent,
) -> genai_types.ContentDict:
"""Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
role = "model" if content.role == "assistant" else content.role
return {"role": role, "parts": content.content}
# Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent
parts = []
if content.content:
parts.append(protos.Part(text=content.content))
if content.tool_calls:
parts.extend(
[
protos.Part(
function_call=protos.FunctionCall(
name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args),
)
)
for tool_call in content.tool_calls
]
)
return protos.Content({"role": "model", "parts": parts})
class GoogleGenerativeAIConversationEntity(
@ -209,15 +247,18 @@ class GoogleGenerativeAIConversationEntity(
self, user_input: conversation.ConversationInput
) -> conversation.ConversationResult:
"""Process a sentence."""
async with conversation.async_get_chat_session(
self.hass, user_input
) as session:
return await self._async_handle_message(user_input, session)
with (
chat_session.async_get_chat_session(
self.hass, user_input.conversation_id
) as session,
conversation.async_get_chat_log(self.hass, session, user_input) as chat_log,
):
return await self._async_handle_message(user_input, chat_log)
async def _async_handle_message(
self,
user_input: conversation.ConversationInput,
session: conversation.ChatSession[genai_types.ContentDict],
chat_log: conversation.ChatLog,
) -> conversation.ConversationResult:
"""Call the API."""
@ -225,7 +266,7 @@ class GoogleGenerativeAIConversationEntity(
options = self.entry.options
try:
await session.async_update_llm_data(
await chat_log.async_update_llm_data(
DOMAIN,
user_input,
options.get(CONF_LLM_HASS_API),
@ -235,10 +276,10 @@ class GoogleGenerativeAIConversationEntity(
return err.as_conversation_result()
tools: list[dict[str, Any]] | None = None
if session.llm_api:
if chat_log.llm_api:
tools = [
_format_tool(tool, session.llm_api.custom_serializer)
for tool in session.llm_api.tools
_format_tool(tool, chat_log.llm_api.custom_serializer)
for tool in chat_log.llm_api.tools
]
model_name = self.entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL)
@ -249,9 +290,36 @@ class GoogleGenerativeAIConversationEntity(
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
)
prompt, *messages = [
_chat_message_convert(message) for message in session.async_get_messages()
]
prompt = chat_log.content[0].content # type: ignore[union-attr]
messages: list[genai_types.ContentDict] = []
# Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = []
for chat_content in chat_log.content[1:]:
if chat_content.role == "tool_result":
# mypy doesn't like picking a type based on checking shared property 'role'
tool_results.append(cast(conversation.ToolResultContent, chat_content))
continue
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
tool_results.clear()
messages.append(
_convert_content(
cast(
conversation.UserContent
| conversation.SystemContent
| conversation.AssistantContent,
chat_content,
)
)
)
if tool_results:
messages.append(_create_google_tool_response_content(tool_results))
model = genai.GenerativeModel(
model_name=model_name,
generation_config={
@ -279,12 +347,12 @@ class GoogleGenerativeAIConversationEntity(
),
},
tools=tools or None,
system_instruction=prompt["parts"] if supports_system_instruction else None,
system_instruction=prompt if supports_system_instruction else None,
)
if not supports_system_instruction:
messages = [
{"role": "user", "parts": prompt["parts"]},
{"role": "user", "parts": prompt},
{"role": "model", "parts": "Ok"},
*messages,
]
@ -322,50 +390,40 @@ class GoogleGenerativeAIConversationEntity(
content = " ".join(
[part.text.strip() for part in chat_response.parts if part.text]
)
if content:
session.async_add_message(
conversation.Content(
role="assistant",
agent_id=user_input.agent_id,
content=content,
)
)
function_calls = [
part.function_call for part in chat_response.parts if part.function_call
]
if not function_calls or not session.llm_api:
break
tool_responses = []
for function_call in function_calls:
tool_call = MessageToDict(function_call._pb) # noqa: SLF001
tool_calls = []
for part in chat_response.parts:
if not part.function_call:
continue
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
tool_name = tool_call["name"]
tool_args = _escape_decode(tool_call["args"])
tool_input = llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
function_response = await session.async_call_tool(tool_input)
tool_responses.append(
protos.Part(
function_response=protos.FunctionResponse(
name=tool_name, response=function_response
tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
)
chat_request = _create_google_tool_response_content(
[
tool_response
async for tool_response in chat_log.async_add_assistant_content(
conversation.AssistantContent(
agent_id=user_input.agent_id,
content=content,
tool_calls=tool_calls or None,
)
)
)
chat_request = protos.Content(parts=tool_responses)
session.async_add_message(
conversation.NativeContent(
agent_id=user_input.agent_id,
content=chat_request,
)
]
)
if not tool_calls:
break
response = intent.IntentResponse(language=user_input.language)
response.async_set_speech(
" ".join([part.text.strip() for part in chat_response.parts if part.text])
)
return conversation.ConversationResult(
response=response, conversation_id=session.conversation_id
response=response, conversation_id=chat_log.conversation_id
)
async def _async_entry_update_listener(

View File

@ -131,5 +131,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/govee_ble",
"iot_class": "local_push",
"requirements": ["govee-ble==0.42.0"]
"requirements": ["govee-ble==0.42.1"]
}

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/habitica",
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"requirements": ["habiticalib==0.3.3"]
"requirements": ["habiticalib==0.3.4"]
}

View File

@ -27,15 +27,19 @@ from homeassistant.components.backup import (
AgentBackup,
BackupAgent,
BackupManagerError,
BackupNotFound,
BackupReaderWriter,
BackupReaderWriterError,
CreateBackupEvent,
CreateBackupStage,
CreateBackupState,
Folder,
IdleEvent,
IncorrectPasswordError,
ManagerBackup,
NewBackup,
RestoreBackupEvent,
RestoreBackupStage,
RestoreBackupState,
WrittenBackup,
async_get_manager as async_get_backup_manager,
@ -47,6 +51,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.util import dt as dt_util
from homeassistant.util.enum import try_parse_enum
from .const import DOMAIN, EVENT_SUPERVISOR_EVENT
from .handler import get_supervisor_client
@ -158,10 +163,15 @@ class SupervisorBackupAgent(BackupAgent):
**kwargs: Any,
) -> AsyncIterator[bytes]:
"""Download a backup file."""
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(location=self.location),
)
try:
return await self._client.backups.download_backup(
backup_id,
options=supervisor_backups.DownloadBackupOptions(
location=self.location
),
)
except SupervisorNotFoundError as err:
raise BackupNotFound from err
async def async_upload_backup(
self,
@ -336,6 +346,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
self._async_wait_for_backup(
backup,
locations,
on_progress=on_progress,
remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
),
name="backup_manager_create_backup",
@ -349,18 +360,29 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
backup: supervisor_backups.NewBackup,
locations: list[str | None],
*,
on_progress: Callable[[CreateBackupEvent], None],
remove_after_upload: bool,
) -> WrittenBackup:
"""Wait for a backup to complete."""
backup_complete = asyncio.Event()
backup_id: str | None = None
create_errors: list[dict[str, str]] = []
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup progress."""
nonlocal backup_id
if not (stage := try_parse_enum(CreateBackupStage, data.get("stage"))):
_LOGGER.debug("Unknown create stage: %s", data.get("stage"))
else:
on_progress(
CreateBackupEvent(
reason=None, stage=stage, state=CreateBackupState.IN_PROGRESS
)
)
if data.get("done") is True:
backup_id = data.get("reference")
create_errors.extend(data.get("errors", []))
backup_complete.set()
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
@ -369,8 +391,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
await backup_complete.wait()
finally:
unsub()
if not backup_id:
raise BackupReaderWriterError("Backup failed")
if not backup_id or create_errors:
# We should add more specific error handling here in the future
raise BackupReaderWriterError(
f"Backup failed: {create_errors or 'no backup_id'}"
)
async def open_backup() -> AsyncIterator[bytes]:
try:
@ -509,6 +534,8 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
location=restore_location,
),
)
except SupervisorNotFoundError as err:
raise BackupNotFound from err
except SupervisorBadRequestError as err:
# Supervisor currently does not transmit machine parsable error types
message = err.args[0]
@ -517,17 +544,30 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
raise HomeAssistantError(message) from err
restore_complete = asyncio.Event()
restore_errors: list[dict[str, str]] = []
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup restore progress."""
if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
_LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
else:
on_progress(
RestoreBackupEvent(
reason=None, stage=stage, state=RestoreBackupState.IN_PROGRESS
)
)
if data.get("done") is True:
restore_complete.set()
restore_errors.extend(data.get("errors", []))
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
try:
await self._get_job_state(job.job_id, on_job_progress)
await restore_complete.wait()
if restore_errors:
# We should add more specific error handling here in the future
raise BackupReaderWriterError(f"Restore failed: {restore_errors}")
finally:
unsub()
@ -543,22 +583,45 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
sent_event = False
@callback
def on_job_progress(data: Mapping[str, Any]) -> None:
"""Handle backup restore progress."""
nonlocal sent_event
if not (stage := try_parse_enum(RestoreBackupStage, data.get("stage"))):
_LOGGER.debug("Unknown restore stage: %s", data.get("stage"))
if data.get("done") is not True:
on_progress(
RestoreBackupEvent(
reason="", stage=None, state=RestoreBackupState.IN_PROGRESS
if stage or not sent_event:
sent_event = True
on_progress(
RestoreBackupEvent(
reason=None,
stage=stage,
state=RestoreBackupState.IN_PROGRESS,
)
)
)
return
on_progress(
RestoreBackupEvent(
reason="", stage=None, state=RestoreBackupState.COMPLETED
restore_errors = data.get("errors", [])
if restore_errors:
_LOGGER.warning("Restore backup failed: %s", restore_errors)
# We should add more specific error handling here in the future
on_progress(
RestoreBackupEvent(
reason="unknown_error",
stage=stage,
state=RestoreBackupState.FAILED,
)
)
else:
on_progress(
RestoreBackupEvent(
reason=None, stage=stage, state=RestoreBackupState.COMPLETED
)
)
)
on_progress(IdleEvent())
unsub()

View File

@ -0,0 +1 @@
"""Virtual integration: Heicko."""

View File

@ -0,0 +1,6 @@
{
"domain": "heicko",
"name": "Heicko",
"integration_type": "virtual",
"supported_by": "motion_blinds"
}

View File

@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
async_delete_issue,
)
from .common import setup_home_connect_entry
from .const import (
BSH_DOOR_STATE_CLOSED,
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect binary sensor."""
entities: list[BinarySensorEntity] = []
for appliance in entry.runtime_data.data.values():
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))
async_add_entities(entities)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):

View 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,),
)
)

View File

@ -3,7 +3,7 @@
import asyncio
from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass, field
from dataclasses import dataclass
import logging
from typing import Any
@ -30,6 +30,7 @@ from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry
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 .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
@ -46,9 +47,9 @@ EVENT_STREAM_RECONNECT_DELAY = 30
class HomeConnectApplianceData:
"""Class to hold Home Connect appliance data."""
events: dict[EventKey, Event] = field(default_factory=dict)
events: dict[EventKey, Event]
info: HomeAppliance
programs: list[EnumerateProgram] = field(default_factory=list)
programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status]
@ -83,6 +84,10 @@ class HomeConnectCoordinator(
name=config_entry.entry_id,
)
self.client = client
self._special_listeners: dict[
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {}
self.device_registry = dr.async_get(self.hass)
@cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
@ -107,6 +112,28 @@ class HomeConnectCoordinator(
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
def start_event_listener(self) -> None:
"""Start event listener."""
@ -161,18 +188,49 @@ class HomeConnectCoordinator(
events[event.key] = event
self._call_event_listener(event_message)
case EventType.CONNECTED:
self.data[event_message_ha_id].info.connected = True
self._call_all_event_listeners_for_appliance(
case EventType.CONNECTED | EventType.PAIRED:
appliance_info = await self.client.get_specific_appliance(
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:
self.data[event_message_ha_id].info.connected = False
self._call_all_event_listeners_for_appliance(
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:
_LOGGER.debug(
"Non-breaking error (%s) while listening for events,"
@ -217,60 +275,101 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error),
) from error
appliances_data = self.data or {}
for appliance in appliances.homeappliances:
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 = {}
appliance_data = HomeConnectApplianceData(
info=appliance, settings=settings, status=status
return {
appliance.ha_id: await self._get_appliance_data(
appliance, self.data.get(appliance.ha_id) if self.data else None
)
if appliance.ha_id in appliances_data:
appliances_data[appliance.ha_id].update(appliance_data)
appliance_data = appliances_data[appliance.ha_id]
for appliance in appliances.homeappliances
}
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:
appliances_data[appliance.ha_id] = appliance_data
if (
appliance.type in APPLIANCES_WITH_PROGRAMS
and not appliance_data.programs
):
try:
appliance_data.programs.extend(
(await self.client.get_all_programs(appliance.ha_id)).programs
)
except HomeConnectError as error:
_LOGGER.debug(
"Error fetching programs for %s: %s",
appliance.ha_id,
error
if isinstance(error, HomeConnectApiError)
else type(error).__name__,
)
return appliances_data
programs.extend(all_programs.programs)
for program, event_key in (
(
all_programs.active,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
(
all_programs.selected,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
),
):
if program and program.key:
events[event_key] = Event(
event_key,
event_key.value,
0,
"",
"",
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

View File

@ -35,9 +35,6 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, appliance.info.ha_id)},
manufacturer=appliance.info.brand,
model=appliance.info.vib,
name=appliance.info.name,
)
self.update_native_value()

View File

@ -20,6 +20,7 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import color as color_util
from .common import setup_home_connect_entry
from .const import (
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect light."""
async_add_entities(
[
HomeConnectLight(entry.runtime_data, appliance, description)
for description in LIGHTS
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)

View File

@ -6,6 +6,6 @@
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/home_connect",
"iot_class": "cloud_push",
"loggers": ["homeconnect"],
"requirements": ["aiohomeconnect==0.12.1"]
"loggers": ["aiohomeconnect"],
"requirements": ["aiohomeconnect==0.12.3"]
}

View File

@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING,
@ -22,7 +23,7 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .coordinator import HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect number."""
async_add_entities(
[
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
for description in NUMBERS
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)

View File

@ -14,6 +14,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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 .coordinator import (
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect select entities."""
async_add_entities(
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for appliance in entry.runtime_data.data.values()
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
@ -110,7 +124,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
or program.constraints.execution in desc.allowed_executions
)
]
self._attr_current_option = None
def update_native_value(self) -> None:
"""Set the program value."""

View File

@ -17,13 +17,14 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
from .common import setup_home_connect_entry
from .const import (
APPLIANCES_WITH_PROGRAMS,
BSH_OPERATION_STATE_FINISHED,
BSH_OPERATION_STATE_PAUSE,
BSH_OPERATION_STATE_RUN,
)
from .coordinator import HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect sensor."""
entities: list[SensorEntity] = []
for appliance in entry.runtime_data.data.values():
entities.extend(
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)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectSensor(HomeConnectEntity, SensorEntity):

View File

@ -21,6 +21,7 @@ from homeassistant.helpers.issue_registry import (
)
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from .common import setup_home_connect_entry
from .const import (
BSH_POWER_OFF,
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
entities: list[SwitchEntity] = []
for appliance in entry.runtime_data.data.values():
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
)
async_add_entities(entities)
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
@ -192,6 +203,7 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
desc = " ".join(
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
)
self.program = program
super().__init__(
coordinator,
appliance,
@ -200,7 +212,6 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
self._attr_name = f"{appliance.info.name} {desc}"
self._attr_unique_id = f"{appliance.info.ha_id}-{desc}"
self._attr_has_entity_name = False
self.program = program
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""

View File

@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
DOMAIN,
SVE_TRANSLATION_KEY_SET_SETTING,
@ -18,7 +19,7 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .coordinator import HomeConnectConfigEntry
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
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(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Home Connect switch."""
async_add_entities(
[
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
for description in TIME_ENTITIES
for appliance in entry.runtime_data.data.values()
if description.key in appliance.settings
],
setup_home_connect_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
)

View File

@ -3,6 +3,7 @@
from pyHomee.const import NodeProfile
from homeassistant.const import (
DEGREE,
LIGHT_LUX,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
@ -34,6 +35,7 @@ HOMEE_UNIT_TO_HA_UNIT = {
"W": UnitOfPower.WATT,
"m/s": UnitOfSpeed.METERS_PER_SECOND,
"km/h": UnitOfSpeed.KILOMETERS_PER_HOUR,
"°": DEGREE,
"°F": UnitOfTemperature.FAHRENHEIT,
"°C": UnitOfTemperature.CELSIUS,
"K": UnitOfTemperature.KELVIN,
@ -53,7 +55,7 @@ OPEN_CLOSE_MAP_REVERSED = {
0.0: "closed",
1.0: "open",
2.0: "partial",
3.0: "cosing",
3.0: "closing",
4.0: "opening",
}
WINDOW_MAP = {

View File

@ -68,6 +68,7 @@ def get_device_class(node: HomeeNode) -> CoverDeviceClass | None:
"""Determine the device class a homee node based on the node profile."""
COVER_DEVICE_PROFILES = {
NodeProfile.GARAGE_DOOR_OPERATOR: CoverDeviceClass.GARAGE,
NodeProfile.ENTRANCE_GATE_OPERATOR: CoverDeviceClass.GATE,
NodeProfile.SHUTTER_POSITION_SWITCH: CoverDeviceClass.SHUTTER,
}
@ -93,6 +94,7 @@ def is_cover_node(node: HomeeNode) -> bool:
return node.profile in [
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH,
NodeProfile.ELECTRIC_MOTOR_METERING_SWITCH_WITHOUT_SLAT_POSITION,
NodeProfile.ENTRANCE_GATE_OPERATOR,
NodeProfile.GARAGE_DOOR_OPERATOR,
NodeProfile.SHUTTER_POSITION_SWITCH,
]

View File

@ -2,7 +2,9 @@
from pyHomee.const import AttributeState, AttributeType, NodeProfile, NodeState
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.entity import Entity
@ -135,7 +137,13 @@ class HomeeNodeEntity(Entity):
async def async_set_value(self, attribute: HomeeAttribute, value: float) -> None:
"""Set an attribute value on the homee node."""
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:
self.schedule_update_ha_state()

View File

@ -177,6 +177,7 @@ SENSOR_DESCRIPTIONS: dict[AttributeType, HomeeSensorEntityDescription] = {
AttributeType.TOTAL_CURRENT: HomeeSensorEntityDescription(
key="total_current",
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
AttributeType.TOTAL_CURRENT_ENERGY_USE: HomeeSensorEntityDescription(
key="total_power",
@ -252,7 +253,7 @@ NODE_SENSOR_DESCRIPTIONS: tuple[HomeeNodeSensorEntityDescription, ...] = (
],
entity_category=EntityCategory.DIAGNOSTIC,
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),
),
)

View File

@ -67,7 +67,23 @@
"name": "Link quality"
},
"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": {
"name": "Operating hours"
@ -192,5 +208,10 @@
"name": "Watchdog"
}
}
},
"exceptions": {
"connection_closed": {
"message": "Could not connect to Homee while setting attribute"
}
}
}

View File

@ -78,6 +78,7 @@ from .const import (
CONF_VIDEO_CODEC,
CONF_VIDEO_MAP,
CONF_VIDEO_PACKET_SIZE,
CONF_VIDEO_PROFILE_NAMES,
DEFAULT_AUDIO_CODEC,
DEFAULT_AUDIO_MAP,
DEFAULT_AUDIO_PACKET_SIZE,
@ -90,6 +91,7 @@ from .const import (
DEFAULT_VIDEO_CODEC,
DEFAULT_VIDEO_MAP,
DEFAULT_VIDEO_PACKET_SIZE,
DEFAULT_VIDEO_PROFILE_NAMES,
DOMAIN,
FEATURE_ON_OFF,
FEATURE_PLAY_PAUSE,
@ -163,6 +165,9 @@ CAMERA_SCHEMA = BASIC_INFO_SCHEMA.extend(
vol.Optional(CONF_VIDEO_CODEC, default=DEFAULT_VIDEO_CODEC): vol.In(
VALID_VIDEO_CODECS
),
vol.Optional(CONF_VIDEO_PROFILE_NAMES, default=DEFAULT_VIDEO_PROFILE_NAMES): [
cv.string
],
vol.Optional(
CONF_AUDIO_PACKET_SIZE, default=DEFAULT_AUDIO_PACKET_SIZE
): cv.positive_int,

View File

@ -25,7 +25,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
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(
entry.data[CONF_IP_ADDRESS],
token=token,
@ -37,7 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) -
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)
try:

View File

@ -272,9 +272,14 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconfigure_entry = self._get_reconfigure_entry()
if user_input:
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:
LOGGER.error(ex)
@ -288,7 +293,6 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN):
self._get_reconfigure_entry(),
data_updates=user_input,
)
reconfigure_entry = self._get_reconfigure_entry()
return self.async_show_form(
step_id="reconfigure",
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.
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
if await has_v2_api(ip_address):
energy_api = HomeWizardEnergyV2(ip_address)
energy_api = HomeWizardEnergyV2(ip_address, token=token)
else:
energy_api = HomeWizardEnergyV1(ip_address)

View File

@ -408,7 +408,7 @@ class HueLight(CoordinatorEntity, LightEntity):
if 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
if mode in ("xy", "hs"):
return ColorMode.HS

View File

@ -87,7 +87,7 @@ class IdasenDeskConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
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):
if (
discovery.address in current_addresses

View File

@ -72,7 +72,7 @@ class INKBIRDConfigFlow(ConfigFlow, domain=DOMAIN):
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):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View 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)

View 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},
)

View File

@ -0,0 +1,5 @@
"""Constants for the IOmeter integration."""
from typing import Final
DOMAIN: Final = "iometer"

View 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)

View 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}",
)

View 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"
}
}
}
}
}

View 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."]
}

View 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

View 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)

View 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