forked from home-assistant/core
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f501b55aed | |||
| eca93f1f4e | |||
| ec53b08e09 | |||
| 63af407f8f | |||
| 6dd2d46328 | |||
| 8db6a6cf17 | |||
| d148bd9b0c | |||
| 773375e7b0 | |||
| 232e99b62e | |||
| bab616fa61 | |||
| 1c4ddb36d5 | |||
| 76570b5144 | |||
| 5dd147e83b | |||
| 9eb383f314 | |||
| 52feeedd2b | |||
| 1b5316b269 | |||
| 708ae09c7a | |||
| 97fcbed6e0 | |||
| a8175b785f | |||
| 64b056fbe9 | |||
| 427c437a68 | |||
| b637129208 | |||
| 4e3e1e91b7 | |||
| 4066289662 | |||
| aca9607e2f | |||
| edabf0f8dd | |||
| 5286bd8f0c | |||
| d206553a0d | |||
| b500fde468 | |||
| 46cef2986c | |||
| 823df4242d |
@@ -40,7 +40,7 @@ env:
|
||||
CACHE_VERSION: 11
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 9
|
||||
HA_SHORT_VERSION: "2025.2"
|
||||
HA_SHORT_VERSION: "2025.3"
|
||||
DEFAULT_PYTHON: "3.13"
|
||||
ALL_PYTHON_VERSIONS: "['3.13']"
|
||||
# 10.3 is the oldest supported version
|
||||
|
||||
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.6
|
||||
uses: github/codeql-action/init@v3.28.8
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.6
|
||||
uses: github/codeql-action/analyze@v3.28.8
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -146,6 +146,7 @@ def _extract_backup(
|
||||
config_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns(*(keep)),
|
||||
ignore_dangling_symlinks=True,
|
||||
)
|
||||
elif restore_content.restore_database:
|
||||
for entry in KEEP_DATABASE:
|
||||
|
||||
@@ -1122,6 +1122,7 @@ class PipelineRun:
|
||||
context=user_input.context,
|
||||
language=user_input.language,
|
||||
agent_id=user_input.agent_id,
|
||||
extra_system_prompt=user_input.extra_system_prompt,
|
||||
)
|
||||
speech = conversation_result.response.speech.get("plain", {}).get(
|
||||
"speech", ""
|
||||
|
||||
@@ -63,6 +63,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"async_internal_announce",
|
||||
[AssistSatelliteEntityFeature.ANNOUNCE],
|
||||
)
|
||||
component.async_register_entity_service(
|
||||
"start_conversation",
|
||||
vol.All(
|
||||
cv.make_entity_service_schema(
|
||||
{
|
||||
vol.Optional("start_message"): str,
|
||||
vol.Optional("start_media_id"): str,
|
||||
vol.Optional("extra_system_prompt"): str,
|
||||
}
|
||||
),
|
||||
cv.has_at_least_one_key("start_message", "start_media_id"),
|
||||
),
|
||||
"async_internal_start_conversation",
|
||||
[AssistSatelliteEntityFeature.START_CONVERSATION],
|
||||
)
|
||||
hass.data[CONNECTION_TEST_DATA] = {}
|
||||
async_register_websocket_api(hass)
|
||||
hass.http.register_view(ConnectionTestView())
|
||||
|
||||
@@ -26,3 +26,6 @@ class AssistSatelliteEntityFeature(IntFlag):
|
||||
|
||||
ANNOUNCE = 1
|
||||
"""Device supports remotely triggered announcements."""
|
||||
|
||||
START_CONVERSATION = 2
|
||||
"""Device supports starting conversations."""
|
||||
|
||||
@@ -10,7 +10,7 @@ import logging
|
||||
import time
|
||||
from typing import Any, Final, Literal, final
|
||||
|
||||
from homeassistant.components import media_source, stt, tts
|
||||
from homeassistant.components import conversation, media_source, stt, tts
|
||||
from homeassistant.components.assist_pipeline import (
|
||||
OPTION_PREFERRED,
|
||||
AudioSettings,
|
||||
@@ -27,6 +27,7 @@ from homeassistant.components.tts import (
|
||||
generate_media_source_id as tts_generate_media_source_id,
|
||||
)
|
||||
from homeassistant.core import Context, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
|
||||
@@ -117,6 +118,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
|
||||
_run_has_tts: bool = False
|
||||
_is_announcing = False
|
||||
_extra_system_prompt: str | None = None
|
||||
_wake_word_intercept_future: asyncio.Future[str | None] | None = None
|
||||
_attr_tts_options: dict[str, Any] | None = None
|
||||
_pipeline_task: asyncio.Task | None = None
|
||||
@@ -216,6 +218,60 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_internal_start_conversation(
|
||||
self,
|
||||
start_message: str | None = None,
|
||||
start_media_id: str | None = None,
|
||||
extra_system_prompt: str | None = None,
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite.
|
||||
|
||||
If start_media_id is not provided, message is synthesized to
|
||||
audio with the selected pipeline.
|
||||
|
||||
If start_media_id is provided, it is played directly. It is possible
|
||||
to omit the message and the satellite will not show any text.
|
||||
|
||||
Calls async_start_conversation.
|
||||
"""
|
||||
await self._cancel_running_pipeline()
|
||||
|
||||
# The Home Assistant built-in agent doesn't support conversations.
|
||||
pipeline = async_get_pipeline(self.hass, self._resolve_pipeline())
|
||||
if pipeline.conversation_engine == conversation.HOME_ASSISTANT_AGENT:
|
||||
raise HomeAssistantError(
|
||||
"Built-in conversation agent does not support starting conversations"
|
||||
)
|
||||
|
||||
if start_message is None:
|
||||
start_message = ""
|
||||
|
||||
announcement = await self._resolve_announcement_media_id(
|
||||
start_message, start_media_id
|
||||
)
|
||||
|
||||
if self._is_announcing:
|
||||
raise SatelliteBusyError
|
||||
|
||||
self._is_announcing = True
|
||||
# Provide our start info to the LLM so it understands context of incoming message
|
||||
if extra_system_prompt is not None:
|
||||
self._extra_system_prompt = extra_system_prompt
|
||||
else:
|
||||
self._extra_system_prompt = start_message or None
|
||||
|
||||
try:
|
||||
await self.async_start_conversation(announcement)
|
||||
finally:
|
||||
self._is_announcing = False
|
||||
self._extra_system_prompt = None
|
||||
|
||||
async def async_start_conversation(
|
||||
self, start_announcement: AssistSatelliteAnnouncement
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def async_accept_pipeline_from_satellite(
|
||||
self,
|
||||
audio_stream: AsyncIterable[bytes],
|
||||
@@ -302,6 +358,7 @@ class AssistSatelliteEntity(entity.Entity):
|
||||
),
|
||||
start_stage=start_stage,
|
||||
end_stage=end_stage,
|
||||
conversation_extra_system_prompt=self._extra_system_prompt,
|
||||
),
|
||||
f"{self.entity_id}_pipeline",
|
||||
)
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
"services": {
|
||||
"announce": {
|
||||
"service": "mdi:bullhorn"
|
||||
},
|
||||
"start_conversation": {
|
||||
"service": "mdi:forum"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,3 +14,23 @@ announce:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
start_conversation:
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
supported_features:
|
||||
- assist_satellite.AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
fields:
|
||||
start_message:
|
||||
required: false
|
||||
example: "You left the lights on in the living room. Turn them off?"
|
||||
selector:
|
||||
text:
|
||||
start_media_id:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
extra_system_prompt:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
|
||||
@@ -25,6 +25,24 @@
|
||||
"description": "The media ID to announce instead of using text-to-speech."
|
||||
}
|
||||
}
|
||||
},
|
||||
"start_conversation": {
|
||||
"name": "Start Conversation",
|
||||
"description": "Start a conversation from a satellite.",
|
||||
"fields": {
|
||||
"start_message": {
|
||||
"name": "Message",
|
||||
"description": "The message to start with."
|
||||
},
|
||||
"start_media_id": {
|
||||
"name": "Media ID",
|
||||
"description": "The media ID to start with instead of using text-to-speech."
|
||||
},
|
||||
"extra_system_prompt": {
|
||||
"name": "Extra system prompt",
|
||||
"description": "Provide background information to the AI about the request."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ from .manager import (
|
||||
ManagerBackup,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
)
|
||||
from .models import AddonInfo, AgentBackup, Folder
|
||||
@@ -54,6 +55,7 @@ __all__ = [
|
||||
"ManagerBackup",
|
||||
"NewBackup",
|
||||
"RestoreBackupEvent",
|
||||
"RestoreBackupState",
|
||||
"WrittenBackup",
|
||||
"async_get_manager",
|
||||
]
|
||||
|
||||
@@ -27,6 +27,12 @@ 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."""
|
||||
|
||||
@@ -94,11 +100,16 @@ class LocalBackupAgent(BackupAgent):
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_backup_path(self, backup_id: str) -> Path:
|
||||
"""Return the local path to a backup.
|
||||
"""Return the local path to an existing backup.
|
||||
|
||||
The method should return the path to the backup file with the specified id.
|
||||
Raises BackupAgentError if the backup does not exist.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
|
||||
|
||||
class BackupAgentPlatformProtocol(Protocol):
|
||||
"""Define the format of backup platforms which implement backup agents."""
|
||||
|
||||
@@ -11,7 +11,7 @@ from typing import Any
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
|
||||
from .agent import BackupAgent, LocalBackupAgent
|
||||
from .agent import BackupAgent, BackupNotFound, LocalBackupAgent
|
||||
from .const import DOMAIN, LOGGER
|
||||
from .models import AgentBackup
|
||||
from .util import read_backup
|
||||
@@ -39,7 +39,7 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._backup_dir = Path(hass.config.path("backups"))
|
||||
self._backups: dict[str, AgentBackup] = {}
|
||||
self._backups: dict[str, tuple[AgentBackup, Path]] = {}
|
||||
self._loaded_backups = False
|
||||
|
||||
async def _load_backups(self) -> None:
|
||||
@@ -49,13 +49,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
self._backups = backups
|
||||
self._loaded_backups = True
|
||||
|
||||
def _read_backups(self) -> dict[str, AgentBackup]:
|
||||
def _read_backups(self) -> dict[str, tuple[AgentBackup, Path]]:
|
||||
"""Read backups from disk."""
|
||||
backups: dict[str, AgentBackup] = {}
|
||||
backups: dict[str, tuple[AgentBackup, Path]] = {}
|
||||
for backup_path in self._backup_dir.glob("*.tar"):
|
||||
try:
|
||||
backup = read_backup(backup_path)
|
||||
backups[backup.backup_id] = backup
|
||||
backups[backup.backup_id] = (backup, backup_path)
|
||||
except (OSError, TarError, json.JSONDecodeError, KeyError) as err:
|
||||
LOGGER.warning("Unable to read backup %s: %s", backup_path, err)
|
||||
return backups
|
||||
@@ -76,13 +76,13 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
self._backups[backup.backup_id] = backup
|
||||
self._backups[backup.backup_id] = (backup, self.get_new_backup_path(backup))
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
if not self._loaded_backups:
|
||||
await self._load_backups()
|
||||
return list(self._backups.values())
|
||||
return [backup for backup, _ in self._backups.values()]
|
||||
|
||||
async def async_get_backup(
|
||||
self,
|
||||
@@ -93,10 +93,10 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
if not self._loaded_backups:
|
||||
await self._load_backups()
|
||||
|
||||
if not (backup := self._backups.get(backup_id)):
|
||||
if backup_id not in self._backups:
|
||||
return None
|
||||
|
||||
backup_path = self.get_backup_path(backup_id)
|
||||
backup, backup_path = self._backups[backup_id]
|
||||
if not await self._hass.async_add_executor_job(backup_path.exists):
|
||||
LOGGER.debug(
|
||||
(
|
||||
@@ -112,15 +112,28 @@ class CoreLocalBackupAgent(LocalBackupAgent):
|
||||
return backup
|
||||
|
||||
def get_backup_path(self, backup_id: str) -> Path:
|
||||
"""Return the local path to a backup."""
|
||||
return self._backup_dir / f"{backup_id}.tar"
|
||||
"""Return the local path to an existing backup.
|
||||
|
||||
Raises BackupAgentError if the backup does not exist.
|
||||
"""
|
||||
try:
|
||||
return self._backups[backup_id][1]
|
||||
except KeyError as err:
|
||||
raise BackupNotFound(f"Backup {backup_id} does not exist") from err
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return self._backup_dir / f"{backup.backup_id}.tar"
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Delete a backup file."""
|
||||
if await self.async_get_backup(backup_id) is None:
|
||||
return
|
||||
if not self._loaded_backups:
|
||||
await self._load_backups()
|
||||
|
||||
backup_path = self.get_backup_path(backup_id)
|
||||
try:
|
||||
backup_path = self.get_backup_path(backup_id)
|
||||
except BackupNotFound:
|
||||
return
|
||||
await self._hass.async_add_executor_job(backup_path.unlink, True)
|
||||
LOGGER.debug("Deleted backup located at %s", backup_path)
|
||||
self._backups.pop(backup_id)
|
||||
|
||||
@@ -1346,10 +1346,24 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
if agent_config and not agent_config.protected:
|
||||
password = None
|
||||
|
||||
backup = AgentBackup(
|
||||
addons=[],
|
||||
backup_id=backup_id,
|
||||
database_included=include_database,
|
||||
date=date_str,
|
||||
extra_metadata=extra_metadata,
|
||||
folders=[],
|
||||
homeassistant_included=True,
|
||||
homeassistant_version=HAVERSION,
|
||||
name=backup_name,
|
||||
protected=password is not None,
|
||||
size=0,
|
||||
)
|
||||
|
||||
local_agent_tar_file_path = None
|
||||
if self._local_agent_id in agent_ids:
|
||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||
local_agent_tar_file_path = local_agent.get_backup_path(backup_id)
|
||||
local_agent_tar_file_path = local_agent.get_new_backup_path(backup)
|
||||
|
||||
on_progress(
|
||||
CreateBackupEvent(
|
||||
@@ -1391,19 +1405,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
# ValueError from json_bytes
|
||||
raise BackupReaderWriterError(str(err)) from err
|
||||
else:
|
||||
backup = AgentBackup(
|
||||
addons=[],
|
||||
backup_id=backup_id,
|
||||
database_included=include_database,
|
||||
date=date_str,
|
||||
extra_metadata=extra_metadata,
|
||||
folders=[],
|
||||
homeassistant_included=True,
|
||||
homeassistant_version=HAVERSION,
|
||||
name=backup_name,
|
||||
protected=password is not None,
|
||||
size=size_in_bytes,
|
||||
)
|
||||
backup = replace(backup, size=size_in_bytes)
|
||||
|
||||
async_add_executor_job = self._hass.async_add_executor_job
|
||||
|
||||
@@ -1517,7 +1519,7 @@ class CoreBackupReaderWriter(BackupReaderWriter):
|
||||
manager = self._hass.data[DATA_MANAGER]
|
||||
if self._local_agent_id in agent_ids:
|
||||
local_agent = manager.local_backup_agents[self._local_agent_id]
|
||||
tar_file_path = local_agent.get_backup_path(backup.backup_id)
|
||||
tar_file_path = local_agent.get_new_backup_path(backup)
|
||||
await async_add_executor_job(make_backup_dir, tar_file_path.parent)
|
||||
await async_add_executor_job(shutil.move, temp_file, tar_file_path)
|
||||
else:
|
||||
|
||||
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 2
|
||||
STORAGE_VERSION_MINOR = 3
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -47,8 +47,10 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
"""Migrate to the new version."""
|
||||
data = old_data
|
||||
if old_major_version == 1:
|
||||
if old_minor_version < 2:
|
||||
# Version 1.2 adds per agent settings, configurable backup time
|
||||
if old_minor_version < 3:
|
||||
# Version 1.2 bumped to 1.3 because 1.2 was changed several
|
||||
# times during development.
|
||||
# Version 1.3 adds per agent settings, configurable backup time
|
||||
# and custom days
|
||||
data["config"]["agents"] = {}
|
||||
data["config"]["schedule"]["time"] = None
|
||||
|
||||
@@ -39,6 +39,10 @@ def async_load_history_from_system(
|
||||
now_monotonic = monotonic_time_coarse()
|
||||
connectable_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
all_loaded_history: dict[str, BluetoothServiceInfoBleak] = {}
|
||||
adapter_to_source_address = {
|
||||
adapter: details[ADAPTER_ADDRESS]
|
||||
for adapter, details in adapters.adapters.items()
|
||||
}
|
||||
|
||||
# Restore local adapters
|
||||
for address, history in adapters.history.items():
|
||||
@@ -50,7 +54,11 @@ def async_load_history_from_system(
|
||||
BluetoothServiceInfoBleak.from_device_and_advertisement_data(
|
||||
history.device,
|
||||
history.advertisement_data,
|
||||
history.source,
|
||||
# history.source is really the adapter name
|
||||
# for historical compatibility since BlueZ
|
||||
# does not know the MAC address of the adapter
|
||||
# so we need to convert it to the source address (MAC)
|
||||
adapter_to_source_address.get(history.source, history.source),
|
||||
now_monotonic,
|
||||
True,
|
||||
)
|
||||
|
||||
@@ -2,13 +2,22 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyenphase import EnvoyData
|
||||
from collections.abc import Callable, Coroutine
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from httpx import HTTPError
|
||||
from pyenphase import EnvoyData
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseUpdateCoordinator
|
||||
|
||||
ACTIONERRORS = (EnvoyError, HTTPError)
|
||||
|
||||
|
||||
class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
||||
"""Defines a base envoy entity."""
|
||||
@@ -33,3 +42,29 @@ class EnvoyBaseEntity(CoordinatorEntity[EnphaseUpdateCoordinator]):
|
||||
data = self.coordinator.envoy.data
|
||||
assert data is not None
|
||||
return data
|
||||
|
||||
|
||||
def exception_handler[_EntityT: EnvoyBaseEntity, **_P](
|
||||
func: Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, Any]],
|
||||
) -> Callable[Concatenate[_EntityT, _P], Coroutine[Any, Any, None]]:
|
||||
"""Decorate Enphase Envoy calls to handle exceptions.
|
||||
|
||||
A decorator that wraps the passed in function, catches enphase_envoy errors.
|
||||
"""
|
||||
|
||||
async def handler(self: _EntityT, *args: _P.args, **kwargs: _P.kwargs) -> None:
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except ACTIONERRORS as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="action_error",
|
||||
translation_placeholders={
|
||||
"host": self.coordinator.envoy.host,
|
||||
"args": error.args[0],
|
||||
"action": func.__name__,
|
||||
"entity": self.entity_id,
|
||||
},
|
||||
) from error
|
||||
|
||||
return handler
|
||||
|
||||
@@ -400,6 +400,9 @@
|
||||
},
|
||||
"envoy_error": {
|
||||
"message": "Error communicating with Envoy API on {host}: {args}"
|
||||
},
|
||||
"action_error": {
|
||||
"message": "Failed to execute {action} for {entity}, host: {host}: {args}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import EnphaseConfigEntry, EnphaseUpdateCoordinator
|
||||
from .entity import EnvoyBaseEntity
|
||||
from .entity import EnvoyBaseEntity, exception_handler
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
@@ -147,11 +147,13 @@ class EnvoyEnpowerSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||
assert enpower is not None
|
||||
return self.entity_description.value_fn(enpower)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the Enpower switch."""
|
||||
await self.entity_description.turn_on_fn(self.envoy)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the Enpower switch."""
|
||||
await self.entity_description.turn_off_fn(self.envoy)
|
||||
@@ -195,11 +197,13 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||
assert relay is not None
|
||||
return self.entity_description.value_fn(relay)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on (close) the dry contact."""
|
||||
if await self.entity_description.turn_on_fn(self.envoy, self.relay_id):
|
||||
self.async_write_ha_state()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off (open) the dry contact."""
|
||||
if await self.entity_description.turn_off_fn(self.envoy, self.relay_id):
|
||||
@@ -252,11 +256,13 @@ class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity):
|
||||
assert self.data.tariff.storage_settings is not None
|
||||
return self.entity_description.value_fn(self.data.tariff.storage_settings)
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the storage settings switch."""
|
||||
await self.entity_description.turn_on_fn(self.envoy)
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
@exception_handler
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the storage switch."""
|
||||
await self.entity_description.turn_off_fn(self.envoy)
|
||||
|
||||
@@ -5,8 +5,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Mapping
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from uuid import UUID
|
||||
|
||||
from aiohasupervisor import SupervisorClient
|
||||
from aiohasupervisor.exceptions import (
|
||||
@@ -33,6 +35,7 @@ from homeassistant.components.backup import (
|
||||
IncorrectPasswordError,
|
||||
NewBackup,
|
||||
RestoreBackupEvent,
|
||||
RestoreBackupState,
|
||||
WrittenBackup,
|
||||
async_get_manager as async_get_backup_manager,
|
||||
)
|
||||
@@ -47,6 +50,7 @@ from .handler import get_supervisor_client
|
||||
LOCATION_CLOUD_BACKUP = ".cloud_backup"
|
||||
LOCATION_LOCAL = ".local"
|
||||
MOUNT_JOBS = ("mount_manager_create_mount", "mount_manager_remove_mount")
|
||||
RESTORE_JOB_ID_ENV = "SUPERVISOR_RESTORE_JOB_ID"
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -97,7 +101,7 @@ def async_register_backup_agents_listener(
|
||||
|
||||
|
||||
def _backup_details_to_agent_backup(
|
||||
details: supervisor_backups.BackupComplete,
|
||||
details: supervisor_backups.BackupComplete, location: str | None
|
||||
) -> AgentBackup:
|
||||
"""Convert a supervisor backup details object to an agent backup."""
|
||||
homeassistant_included = details.homeassistant is not None
|
||||
@@ -109,6 +113,7 @@ def _backup_details_to_agent_backup(
|
||||
AddonInfo(name=addon.name, slug=addon.slug, version=addon.version)
|
||||
for addon in details.addons
|
||||
]
|
||||
location = location or LOCATION_LOCAL
|
||||
return AgentBackup(
|
||||
addons=addons,
|
||||
backup_id=details.slug,
|
||||
@@ -119,8 +124,8 @@ def _backup_details_to_agent_backup(
|
||||
homeassistant_included=homeassistant_included,
|
||||
homeassistant_version=details.homeassistant,
|
||||
name=details.name,
|
||||
protected=details.protected,
|
||||
size=details.size_bytes,
|
||||
protected=details.location_attributes[location].protected,
|
||||
size=details.location_attributes[location].size_bytes,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,8 +163,23 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
) -> None:
|
||||
"""Upload a backup.
|
||||
|
||||
Not required for supervisor, the SupervisorBackupReaderWriter stores files.
|
||||
The upload will be skipped if the backup already exists in the agent's location.
|
||||
"""
|
||||
if await self.async_get_backup(backup.backup_id):
|
||||
_LOGGER.debug(
|
||||
"Backup %s already exists in location %s",
|
||||
backup.backup_id,
|
||||
self.location,
|
||||
)
|
||||
return
|
||||
stream = await open_stream()
|
||||
upload_options = supervisor_backups.UploadBackupOptions(
|
||||
location={self.location}
|
||||
)
|
||||
await self._client.backups.upload_backup(
|
||||
stream,
|
||||
upload_options,
|
||||
)
|
||||
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
@@ -169,7 +189,7 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
if not backup.locations or self.location not in backup.locations:
|
||||
continue
|
||||
details = await self._client.backups.backup_info(backup.slug)
|
||||
result.append(_backup_details_to_agent_backup(details))
|
||||
result.append(_backup_details_to_agent_backup(details, self.location))
|
||||
return result
|
||||
|
||||
async def async_get_backup(
|
||||
@@ -178,10 +198,13 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
try:
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
except SupervisorNotFoundError:
|
||||
return None
|
||||
if self.location not in details.locations:
|
||||
return None
|
||||
return _backup_details_to_agent_backup(details)
|
||||
return _backup_details_to_agent_backup(details, self.location)
|
||||
|
||||
async def async_delete_backup(self, backup_id: str, **kwargs: Any) -> None:
|
||||
"""Remove a backup."""
|
||||
@@ -192,10 +215,6 @@ class SupervisorBackupAgent(BackupAgent):
|
||||
location={self.location}
|
||||
),
|
||||
)
|
||||
except SupervisorBadRequestError as err:
|
||||
if err.args[0] != "Backup does not exist":
|
||||
raise
|
||||
_LOGGER.debug("Backup %s does not exist", backup_id)
|
||||
except SupervisorNotFoundError:
|
||||
_LOGGER.debug("Backup %s does not exist", backup_id)
|
||||
|
||||
@@ -246,7 +265,41 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
for agent_id in agent_ids
|
||||
if manager.backup_agents[agent_id].domain == DOMAIN
|
||||
]
|
||||
locations = [agent.location for agent in hassio_agents]
|
||||
|
||||
# Supervisor does not support creating backups spread across multiple
|
||||
# locations, where some locations are encrypted and some are not.
|
||||
# It's inefficient to let core do all the copying so we want to let
|
||||
# supervisor handle as much as possible.
|
||||
# Therefore, we split the locations into two lists: encrypted and decrypted.
|
||||
# The longest list will be sent to supervisor, and the remaining locations
|
||||
# will be handled by async_upload_backup.
|
||||
# If the lists are the same length, it does not matter which one we send,
|
||||
# we send the encrypted list to have a well defined behavior.
|
||||
encrypted_locations: list[str | None] = []
|
||||
decrypted_locations: list[str | None] = []
|
||||
agents_settings = manager.config.data.agents
|
||||
for hassio_agent in hassio_agents:
|
||||
if password is not None:
|
||||
if agent_settings := agents_settings.get(hassio_agent.agent_id):
|
||||
if agent_settings.protected:
|
||||
encrypted_locations.append(hassio_agent.location)
|
||||
else:
|
||||
decrypted_locations.append(hassio_agent.location)
|
||||
else:
|
||||
encrypted_locations.append(hassio_agent.location)
|
||||
else:
|
||||
decrypted_locations.append(hassio_agent.location)
|
||||
_LOGGER.debug("Encrypted locations: %s", encrypted_locations)
|
||||
_LOGGER.debug("Decrypted locations: %s", decrypted_locations)
|
||||
if hassio_agents:
|
||||
if len(encrypted_locations) >= len(decrypted_locations):
|
||||
locations = encrypted_locations
|
||||
else:
|
||||
locations = decrypted_locations
|
||||
password = None
|
||||
else:
|
||||
locations = []
|
||||
locations = locations or [LOCATION_CLOUD_BACKUP]
|
||||
|
||||
try:
|
||||
backup = await self._client.backups.partial_backup(
|
||||
@@ -257,7 +310,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
name=backup_name,
|
||||
password=password,
|
||||
compressed=True,
|
||||
location=locations or LOCATION_CLOUD_BACKUP,
|
||||
location=locations,
|
||||
homeassistant_exclude_database=not include_database,
|
||||
background=True,
|
||||
extra=extra_metadata,
|
||||
@@ -267,7 +320,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
raise BackupReaderWriterError(f"Error creating backup: {err}") from err
|
||||
backup_task = self._hass.async_create_task(
|
||||
self._async_wait_for_backup(
|
||||
backup, remove_after_upload=not bool(locations)
|
||||
backup,
|
||||
locations,
|
||||
remove_after_upload=locations == [LOCATION_CLOUD_BACKUP],
|
||||
),
|
||||
name="backup_manager_create_backup",
|
||||
eager_start=False, # To ensure the task is not started before we return
|
||||
@@ -276,7 +331,11 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
return (NewBackup(backup_job_id=backup.job_id), backup_task)
|
||||
|
||||
async def _async_wait_for_backup(
|
||||
self, backup: supervisor_backups.NewBackup, *, remove_after_upload: bool
|
||||
self,
|
||||
backup: supervisor_backups.NewBackup,
|
||||
locations: list[str | None],
|
||||
*,
|
||||
remove_after_upload: bool,
|
||||
) -> WrittenBackup:
|
||||
"""Wait for a backup to complete."""
|
||||
backup_complete = asyncio.Event()
|
||||
@@ -290,8 +349,9 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
backup_id = data.get("reference")
|
||||
backup_complete.set()
|
||||
|
||||
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
|
||||
try:
|
||||
unsub = self._async_listen_job_events(backup.job_id, on_job_progress)
|
||||
await self._get_job_state(backup.job_id, on_job_progress)
|
||||
await backup_complete.wait()
|
||||
finally:
|
||||
unsub()
|
||||
@@ -327,7 +387,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
) from err
|
||||
|
||||
return WrittenBackup(
|
||||
backup=_backup_details_to_agent_backup(details),
|
||||
backup=_backup_details_to_agent_backup(details, locations[0]),
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
)
|
||||
@@ -347,20 +407,19 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
for agent_id in agent_ids
|
||||
if manager.backup_agents[agent_id].domain == DOMAIN
|
||||
]
|
||||
locations = {agent.location for agent in hassio_agents}
|
||||
locations = [agent.location for agent in hassio_agents]
|
||||
locations = locations or [LOCATION_CLOUD_BACKUP]
|
||||
|
||||
backup_id = await self._client.backups.upload_backup(
|
||||
stream,
|
||||
supervisor_backups.UploadBackupOptions(
|
||||
location=locations or {LOCATION_CLOUD_BACKUP}
|
||||
),
|
||||
supervisor_backups.UploadBackupOptions(location=set(locations)),
|
||||
)
|
||||
|
||||
async def open_backup() -> AsyncIterator[bytes]:
|
||||
return await self._client.backups.download_backup(backup_id)
|
||||
|
||||
async def remove_backup() -> None:
|
||||
if locations:
|
||||
if locations != [LOCATION_CLOUD_BACKUP]:
|
||||
return
|
||||
await self._client.backups.remove_backup(
|
||||
backup_id,
|
||||
@@ -372,7 +431,7 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
details = await self._client.backups.backup_info(backup_id)
|
||||
|
||||
return WrittenBackup(
|
||||
backup=_backup_details_to_agent_backup(details),
|
||||
backup=_backup_details_to_agent_backup(details, locations[0]),
|
||||
open_stream=open_backup,
|
||||
release_stream=remove_backup,
|
||||
)
|
||||
@@ -447,12 +506,13 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
|
||||
@callback
|
||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||
"""Handle backup progress."""
|
||||
"""Handle backup restore progress."""
|
||||
if data.get("done") is True:
|
||||
restore_complete.set()
|
||||
|
||||
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
|
||||
try:
|
||||
unsub = self._async_listen_job_events(job.job_id, on_job_progress)
|
||||
await self._get_job_state(job.job_id, on_job_progress)
|
||||
await restore_complete.wait()
|
||||
finally:
|
||||
unsub()
|
||||
@@ -463,6 +523,37 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
on_progress: Callable[[RestoreBackupEvent | IdleEvent], None],
|
||||
) -> None:
|
||||
"""Check restore status after core restart."""
|
||||
if not (restore_job_id := os.environ.get(RESTORE_JOB_ID_ENV)):
|
||||
_LOGGER.debug("No restore job ID found in environment")
|
||||
return
|
||||
|
||||
_LOGGER.debug("Found restore job ID %s in environment", restore_job_id)
|
||||
|
||||
@callback
|
||||
def on_job_progress(data: Mapping[str, Any]) -> None:
|
||||
"""Handle backup restore progress."""
|
||||
if data.get("done") is not True:
|
||||
on_progress(
|
||||
RestoreBackupEvent(
|
||||
reason="", stage=None, state=RestoreBackupState.IN_PROGRESS
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
on_progress(
|
||||
RestoreBackupEvent(
|
||||
reason="", stage=None, state=RestoreBackupState.COMPLETED
|
||||
)
|
||||
)
|
||||
on_progress(IdleEvent())
|
||||
unsub()
|
||||
|
||||
unsub = self._async_listen_job_events(restore_job_id, on_job_progress)
|
||||
try:
|
||||
await self._get_job_state(restore_job_id, on_job_progress)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.debug("Could not get restore job %s: %s", restore_job_id, err)
|
||||
unsub()
|
||||
|
||||
@callback
|
||||
def _async_listen_job_events(
|
||||
@@ -491,6 +582,14 @@ class SupervisorBackupReaderWriter(BackupReaderWriter):
|
||||
)
|
||||
return unsub
|
||||
|
||||
async def _get_job_state(
|
||||
self, job_id: str, on_event: Callable[[Mapping[str, Any]], None]
|
||||
) -> None:
|
||||
"""Poll a job for its state."""
|
||||
job = await self._client.jobs.get_job(UUID(job_id))
|
||||
_LOGGER.debug("Job state: %s", job)
|
||||
on_event(job.to_dict())
|
||||
|
||||
|
||||
async def _default_agent(client: SupervisorClient) -> str:
|
||||
"""Return the default agent for creating a backup."""
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, cast
|
||||
|
||||
from requests import HTTPError
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import CommandKey, Option, OptionKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_DEVICE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import (
|
||||
config_entry_oauth2_flow,
|
||||
@@ -21,16 +20,13 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from . import api
|
||||
from .api import AsyncConfigEntryAuth
|
||||
from .const import (
|
||||
ATTR_KEY,
|
||||
ATTR_PROGRAM,
|
||||
ATTR_UNIT,
|
||||
ATTR_VALUE,
|
||||
BSH_PAUSE,
|
||||
BSH_RESUME,
|
||||
DOMAIN,
|
||||
OLD_NEW_UNIQUE_ID_SUFFIX_MAP,
|
||||
SERVICE_OPTION_ACTIVE,
|
||||
@@ -44,15 +40,11 @@ from .const import (
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
)
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[api.ConfigEntryAuth]
|
||||
from .coordinator import HomeConnectConfigEntry, HomeConnectCoordinator
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
SERVICE_SETTING_SCHEMA = vol.Schema(
|
||||
@@ -99,17 +91,24 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
def _get_appliance(
|
||||
hass: HomeAssistant,
|
||||
device_id: str | None = None,
|
||||
device_entry: dr.DeviceEntry | None = None,
|
||||
entry: HomeConnectConfigEntry | None = None,
|
||||
) -> api.HomeConnectAppliance:
|
||||
"""Return a Home Connect appliance instance given a device id or a device entry."""
|
||||
if device_id is not None and device_entry is None:
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
assert device_entry, "Either a device id or a device entry must be provided"
|
||||
async def _get_client_and_ha_id(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> tuple[HomeConnectClient, str]:
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(device_id)
|
||||
if device_entry is None:
|
||||
raise ServiceValidationError("Device entry not found for device id")
|
||||
entry: HomeConnectConfigEntry | None = None
|
||||
for entry_id in device_entry.config_entries:
|
||||
_entry = hass.config_entries.async_get_entry(entry_id)
|
||||
assert _entry
|
||||
if _entry.domain == DOMAIN:
|
||||
entry = cast(HomeConnectConfigEntry, _entry)
|
||||
break
|
||||
if entry is None:
|
||||
raise ServiceValidationError(
|
||||
"Home Connect config entry not found for that device id"
|
||||
)
|
||||
|
||||
ha_id = next(
|
||||
(
|
||||
@@ -119,158 +118,148 @@ def _get_appliance(
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert ha_id
|
||||
|
||||
def find_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
) -> api.HomeConnectAppliance | None:
|
||||
for device in entry.runtime_data.devices:
|
||||
appliance = device.appliance
|
||||
if appliance.haId == ha_id:
|
||||
return appliance
|
||||
return None
|
||||
|
||||
if entry is None:
|
||||
for entry_id in device_entry.config_entries:
|
||||
entry = hass.config_entries.async_get_entry(entry_id)
|
||||
assert entry
|
||||
if entry.domain == DOMAIN:
|
||||
entry = cast(HomeConnectConfigEntry, entry)
|
||||
if (appliance := find_appliance(entry)) is not None:
|
||||
return appliance
|
||||
elif (appliance := find_appliance(entry)) is not None:
|
||||
return appliance
|
||||
raise ValueError(f"Appliance for device id {device_entry.id} not found")
|
||||
|
||||
|
||||
def _get_appliance_or_raise_service_validation_error(
|
||||
hass: HomeAssistant, device_id: str
|
||||
) -> api.HomeConnectAppliance:
|
||||
"""Return a Home Connect appliance instance or raise a service validation error."""
|
||||
try:
|
||||
return _get_appliance(hass, device_id)
|
||||
except (ValueError, AssertionError) as err:
|
||||
if ha_id is None:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="appliance_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def _run_appliance_service[*_Ts](
|
||||
hass: HomeAssistant,
|
||||
appliance: api.HomeConnectAppliance,
|
||||
method: str,
|
||||
*args: *_Ts,
|
||||
error_translation_key: str,
|
||||
error_translation_placeholders: dict[str, str],
|
||||
) -> None:
|
||||
try:
|
||||
await hass.async_add_executor_job(getattr(appliance, method), *args)
|
||||
except api.HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=error_translation_key,
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
**error_translation_placeholders,
|
||||
},
|
||||
) from err
|
||||
)
|
||||
return entry.runtime_data.client, ha_id
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Home Connect component."""
|
||||
|
||||
async def _async_service_program(call, method):
|
||||
async def _async_service_program(call: ServiceCall, start: bool):
|
||||
"""Execute calls to services taking a program."""
|
||||
program = call.data[ATTR_PROGRAM]
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
|
||||
options = []
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
option_key = call.data.get(ATTR_KEY)
|
||||
if option_key is not None:
|
||||
option = {ATTR_KEY: option_key, ATTR_VALUE: call.data[ATTR_VALUE]}
|
||||
|
||||
option_unit = call.data.get(ATTR_UNIT)
|
||||
if option_unit is not None:
|
||||
option[ATTR_UNIT] = option_unit
|
||||
|
||||
options.append(option)
|
||||
await _run_appliance_service(
|
||||
hass,
|
||||
_get_appliance_or_raise_service_validation_error(hass, device_id),
|
||||
method,
|
||||
program,
|
||||
options,
|
||||
error_translation_key=method,
|
||||
error_translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
|
||||
},
|
||||
options = (
|
||||
[
|
||||
Option(
|
||||
OptionKey(option_key),
|
||||
call.data[ATTR_VALUE],
|
||||
unit=call.data.get(ATTR_UNIT),
|
||||
)
|
||||
]
|
||||
if option_key is not None
|
||||
else None
|
||||
)
|
||||
|
||||
async def _async_service_command(call, command):
|
||||
"""Execute calls to services executing a command."""
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
try:
|
||||
if start:
|
||||
await client.start_program(ha_id, program_key=program, options=options)
|
||||
else:
|
||||
await client.set_selected_program(
|
||||
ha_id, program_key=program, options=options
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="start_program" if start else "select_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program,
|
||||
},
|
||||
) from err
|
||||
|
||||
appliance = _get_appliance_or_raise_service_validation_error(hass, device_id)
|
||||
await _run_appliance_service(
|
||||
hass,
|
||||
appliance,
|
||||
"execute_command",
|
||||
command,
|
||||
error_translation_key="execute_command",
|
||||
error_translation_placeholders={"command": command},
|
||||
)
|
||||
|
||||
async def _async_service_key_value(call, method):
|
||||
"""Execute calls to services taking a key and value."""
|
||||
key = call.data[ATTR_KEY]
|
||||
async def _async_service_set_program_options(call: ServiceCall, active: bool):
|
||||
"""Execute calls to services taking a program."""
|
||||
option_key = call.data[ATTR_KEY]
|
||||
value = call.data[ATTR_VALUE]
|
||||
unit = call.data.get(ATTR_UNIT)
|
||||
device_id = call.data[ATTR_DEVICE_ID]
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
await _run_appliance_service(
|
||||
hass,
|
||||
_get_appliance_or_raise_service_validation_error(hass, device_id),
|
||||
method,
|
||||
*((key, value) if unit is None else (key, value, unit)),
|
||||
error_translation_key=method,
|
||||
error_translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
|
||||
},
|
||||
)
|
||||
try:
|
||||
if active:
|
||||
await client.set_active_program_option(
|
||||
ha_id,
|
||||
option_key=OptionKey(option_key),
|
||||
value=value,
|
||||
unit=unit,
|
||||
)
|
||||
else:
|
||||
await client.set_selected_program_option(
|
||||
ha_id,
|
||||
option_key=OptionKey(option_key),
|
||||
value=value,
|
||||
unit=unit,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_options_active_program"
|
||||
if active
|
||||
else "set_options_selected_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: option_key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_service_option_active(call):
|
||||
async def _async_service_command(call: ServiceCall, command_key: CommandKey):
|
||||
"""Execute calls to services executing a command."""
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
try:
|
||||
await client.put_command(ha_id, command_key=command_key, value=True)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="execute_command",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"command": command_key.value,
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_service_option_active(call: ServiceCall):
|
||||
"""Service for setting an option for an active program."""
|
||||
await _async_service_key_value(call, "set_options_active_program")
|
||||
await _async_service_set_program_options(call, True)
|
||||
|
||||
async def async_service_option_selected(call):
|
||||
async def async_service_option_selected(call: ServiceCall):
|
||||
"""Service for setting an option for a selected program."""
|
||||
await _async_service_key_value(call, "set_options_selected_program")
|
||||
await _async_service_set_program_options(call, False)
|
||||
|
||||
async def async_service_setting(call):
|
||||
async def async_service_setting(call: ServiceCall):
|
||||
"""Service for changing a setting."""
|
||||
await _async_service_key_value(call, "set_setting")
|
||||
key = call.data[ATTR_KEY]
|
||||
value = call.data[ATTR_VALUE]
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async def async_service_pause_program(call):
|
||||
try:
|
||||
await client.set_setting(ha_id, setting_key=key, value=value)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_setting",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: str(value),
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_service_pause_program(call: ServiceCall):
|
||||
"""Service for pausing a program."""
|
||||
await _async_service_command(call, BSH_PAUSE)
|
||||
await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
|
||||
|
||||
async def async_service_resume_program(call):
|
||||
async def async_service_resume_program(call: ServiceCall):
|
||||
"""Service for resuming a paused program."""
|
||||
await _async_service_command(call, BSH_RESUME)
|
||||
await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
|
||||
|
||||
async def async_service_select_program(call):
|
||||
async def async_service_select_program(call: ServiceCall):
|
||||
"""Service for selecting a program."""
|
||||
await _async_service_program(call, "select_program")
|
||||
await _async_service_program(call, False)
|
||||
|
||||
async def async_service_start_program(call):
|
||||
async def async_service_start_program(call: ServiceCall):
|
||||
"""Service for starting a program."""
|
||||
await _async_service_program(call, "start_program")
|
||||
await _async_service_program(call, True)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
@@ -323,12 +312,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: HomeConnectConfigEntry)
|
||||
)
|
||||
)
|
||||
|
||||
entry.runtime_data = api.ConfigEntryAuth(hass, entry, implementation)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
|
||||
await update_all_devices(hass, entry)
|
||||
config_entry_auth = AsyncConfigEntryAuth(hass, session)
|
||||
|
||||
home_connect_client = HomeConnectClient(config_entry_auth)
|
||||
|
||||
coordinator = HomeConnectCoordinator(hass, entry, home_connect_client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
entry.runtime_data.start_event_listener()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -339,21 +337,6 @@ async def async_unload_entry(
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
@Throttle(SCAN_INTERVAL)
|
||||
async def update_all_devices(
|
||||
hass: HomeAssistant, entry: HomeConnectConfigEntry
|
||||
) -> None:
|
||||
"""Update all the devices."""
|
||||
hc_api = entry.runtime_data
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(hc_api.get_devices)
|
||||
for device in hc_api.devices:
|
||||
await hass.async_add_executor_job(device.initialize)
|
||||
except HTTPError as err:
|
||||
_LOGGER.warning("Cannot update devices: %s", err.response.status_code)
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, entry: HomeConnectConfigEntry
|
||||
) -> bool:
|
||||
@@ -382,25 +365,3 @@ async def async_migrate_entry(
|
||||
|
||||
_LOGGER.debug("Migration to version %s successful", entry.version)
|
||||
return True
|
||||
|
||||
|
||||
def get_dict_from_home_connect_error(err: api.HomeConnectError) -> dict[str, Any]:
|
||||
"""Return a dict from a Home Connect error."""
|
||||
return {
|
||||
"description": cast(dict[str, Any], err.args[0]).get("description", "?")
|
||||
if len(err.args) > 0 and isinstance(err.args[0], dict)
|
||||
else err.args[0]
|
||||
if len(err.args) > 0 and isinstance(err.args[0], str)
|
||||
else "?",
|
||||
}
|
||||
|
||||
|
||||
def bsh_key_to_translation_key(bsh_key: str) -> str:
|
||||
"""Convert a BSH key to a translation key format.
|
||||
|
||||
This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
|
||||
and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
|
||||
"""
|
||||
return "_".join(
|
||||
RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
|
||||
).lower()
|
||||
|
||||
@@ -1,85 +1,28 @@
|
||||
"""API for Home Connect bound to HASS OAuth."""
|
||||
|
||||
from asyncio import run_coroutine_threadsafe
|
||||
import logging
|
||||
from aiohomeconnect.client import AbstractAuth
|
||||
from aiohomeconnect.const import API_ENDPOINT
|
||||
|
||||
import homeconnect
|
||||
from homeconnect.api import HomeConnectAppliance, HomeConnectError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
from homeassistant.helpers.dispatcher import dispatcher_send
|
||||
|
||||
from .const import ATTR_KEY, ATTR_VALUE, BSH_ACTIVE_PROGRAM, SIGNAL_UPDATE_ENTITIES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
|
||||
|
||||
class ConfigEntryAuth(homeconnect.HomeConnectAPI):
|
||||
class AsyncConfigEntryAuth(AbstractAuth):
|
||||
"""Provide Home Connect authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
|
||||
oauth_session: config_entry_oauth2_flow.OAuth2Session,
|
||||
) -> None:
|
||||
"""Initialize Home Connect Auth."""
|
||||
self.hass = hass
|
||||
self.config_entry = config_entry
|
||||
self.session = config_entry_oauth2_flow.OAuth2Session(
|
||||
hass, config_entry, implementation
|
||||
)
|
||||
super().__init__(self.session.token)
|
||||
self.devices: list[HomeConnectDevice] = []
|
||||
super().__init__(get_async_client(hass), host=API_ENDPOINT)
|
||||
self.session = oauth_session
|
||||
|
||||
def refresh_tokens(self) -> dict:
|
||||
"""Refresh and return new Home Connect tokens using Home Assistant OAuth2 session."""
|
||||
run_coroutine_threadsafe(
|
||||
self.session.async_ensure_token_valid(), self.hass.loop
|
||||
).result()
|
||||
async def async_get_access_token(self) -> str:
|
||||
"""Return a valid access token."""
|
||||
await self.session.async_ensure_token_valid()
|
||||
|
||||
return self.session.token
|
||||
|
||||
def get_devices(self) -> list[HomeConnectAppliance]:
|
||||
"""Get a dictionary of devices."""
|
||||
appl: list[HomeConnectAppliance] = self.get_appliances()
|
||||
self.devices = [HomeConnectDevice(self.hass, app) for app in appl]
|
||||
return self.devices
|
||||
|
||||
|
||||
class HomeConnectDevice:
|
||||
"""Generic Home Connect device."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, appliance: HomeConnectAppliance) -> None:
|
||||
"""Initialize the device class."""
|
||||
self.hass = hass
|
||||
self.appliance = appliance
|
||||
|
||||
def initialize(self) -> None:
|
||||
"""Fetch the info needed to initialize the device."""
|
||||
try:
|
||||
self.appliance.get_status()
|
||||
except (HomeConnectError, ValueError):
|
||||
_LOGGER.debug("Unable to fetch appliance status. Probably offline")
|
||||
try:
|
||||
self.appliance.get_settings()
|
||||
except (HomeConnectError, ValueError):
|
||||
_LOGGER.debug("Unable to fetch settings. Probably offline")
|
||||
try:
|
||||
program_active = self.appliance.get_programs_active()
|
||||
except (HomeConnectError, ValueError):
|
||||
_LOGGER.debug("Unable to fetch active programs. Probably offline")
|
||||
program_active = None
|
||||
if program_active and ATTR_KEY in program_active:
|
||||
self.appliance.status[BSH_ACTIVE_PROGRAM] = {
|
||||
ATTR_VALUE: program_active[ATTR_KEY]
|
||||
}
|
||||
self.appliance.listen_events(callback=self.event_callback)
|
||||
|
||||
def event_callback(self, appliance: HomeConnectAppliance) -> None:
|
||||
"""Handle event."""
|
||||
_LOGGER.debug("Update triggered on %s", appliance.name)
|
||||
_LOGGER.debug(self.appliance.status)
|
||||
dispatcher_send(self.hass, SIGNAL_UPDATE_ENTITIES, appliance.haId)
|
||||
return self.session.token["access_token"]
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Application credentials platform for Home Connect."""
|
||||
|
||||
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
from homeassistant.components.application_credentials import AuthorizationServer
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
|
||||
|
||||
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
|
||||
"""Return authorization server."""
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Provides a binary sensor for Home Connect."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import StatusKey
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.binary_sensor import (
|
||||
@@ -19,26 +21,21 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_delete_issue,
|
||||
)
|
||||
|
||||
from . import HomeConnectConfigEntry
|
||||
from .api import HomeConnectDevice
|
||||
from .const import (
|
||||
ATTR_VALUE,
|
||||
BSH_DOOR_STATE,
|
||||
BSH_DOOR_STATE_CLOSED,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
BSH_REMOTE_CONTROL_ACTIVATION_STATE,
|
||||
BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
DOMAIN,
|
||||
REFRIGERATION_STATUS_DOOR_CHILLER,
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED,
|
||||
REFRIGERATION_STATUS_DOOR_FREEZER,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN,
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
)
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REFRIGERATION_DOOR_BOOLEAN_MAP = {
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED: False,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN: True,
|
||||
@@ -54,19 +51,19 @@ class HomeConnectBinarySensorEntityDescription(BinarySensorEntityDescription):
|
||||
|
||||
BINARY_SENSORS = (
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=BSH_REMOTE_CONTROL_ACTIVATION_STATE,
|
||||
key=StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
|
||||
translation_key="remote_control",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
key=StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
|
||||
translation_key="remote_start",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="BSH.Common.Status.LocalControlActive",
|
||||
key=StatusKey.BSH_COMMON_LOCAL_CONTROL_ACTIVE,
|
||||
translation_key="local_control",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="BSH.Common.Status.BatteryChargingState",
|
||||
key=StatusKey.BSH_COMMON_BATTERY_CHARGING_STATE,
|
||||
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
|
||||
boolean_map={
|
||||
"BSH.Common.EnumType.BatteryChargingState.Charging": True,
|
||||
@@ -75,7 +72,7 @@ BINARY_SENSORS = (
|
||||
translation_key="battery_charging_state",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="BSH.Common.Status.ChargingConnection",
|
||||
key=StatusKey.BSH_COMMON_CHARGING_CONNECTION,
|
||||
device_class=BinarySensorDeviceClass.PLUG,
|
||||
boolean_map={
|
||||
"BSH.Common.EnumType.ChargingConnection.Connected": True,
|
||||
@@ -84,31 +81,31 @@ BINARY_SENSORS = (
|
||||
translation_key="charging_connection",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.DustBoxInserted",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_DUST_BOX_INSERTED,
|
||||
translation_key="dust_box_inserted",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.Lifted",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LIFTED,
|
||||
translation_key="lifted",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.Lost",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST,
|
||||
translation_key="lost",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=REFRIGERATION_STATUS_DOOR_CHILLER,
|
||||
key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON,
|
||||
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
translation_key="chiller_door",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=REFRIGERATION_STATUS_DOOR_FREEZER,
|
||||
key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
|
||||
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
translation_key="freezer_door",
|
||||
),
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
key=StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
|
||||
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
translation_key="refrigerator_door",
|
||||
@@ -123,19 +120,17 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect binary sensor."""
|
||||
|
||||
def get_entities() -> list[BinarySensorEntity]:
|
||||
entities: list[BinarySensorEntity] = []
|
||||
for device in entry.runtime_data.devices:
|
||||
entities.extend(
|
||||
HomeConnectBinarySensor(device, description)
|
||||
for description in BINARY_SENSORS
|
||||
if description.key in device.appliance.status
|
||||
)
|
||||
if BSH_DOOR_STATE in device.appliance.status:
|
||||
entities.append(HomeConnectDoorBinarySensor(device))
|
||||
return entities
|
||||
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(await hass.async_add_executor_job(get_entities), True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
||||
@@ -143,25 +138,15 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: HomeConnectBinarySensorEntityDescription
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if the binary sensor is available."""
|
||||
return self._attr_is_on is not None
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the binary sensor's status."""
|
||||
if not self.device.appliance.status or not (
|
||||
status := self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
|
||||
):
|
||||
self._attr_is_on = None
|
||||
return
|
||||
if self.entity_description.boolean_map:
|
||||
self._attr_is_on = self.entity_description.boolean_map.get(status)
|
||||
elif status not in [True, False]:
|
||||
self._attr_is_on = None
|
||||
else:
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the native value of the binary sensor."""
|
||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||
if isinstance(status, bool):
|
||||
self._attr_is_on = status
|
||||
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
|
||||
elif self.entity_description.boolean_map:
|
||||
self._attr_is_on = self.entity_description.boolean_map.get(status)
|
||||
else:
|
||||
self._attr_is_on = None
|
||||
|
||||
|
||||
class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
@@ -171,13 +156,15 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: HomeConnectDevice,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
device,
|
||||
coordinator,
|
||||
appliance,
|
||||
HomeConnectBinarySensorEntityDescription(
|
||||
key=BSH_DOOR_STATE,
|
||||
key=StatusKey.BSH_COMMON_DOOR_STATE,
|
||||
device_class=BinarySensorDeviceClass.DOOR,
|
||||
boolean_map={
|
||||
BSH_DOOR_STATE_CLOSED: False,
|
||||
@@ -186,8 +173,8 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
},
|
||||
),
|
||||
)
|
||||
self._attr_unique_id = f"{device.appliance.haId}-Door"
|
||||
self._attr_name = f"{device.appliance.name} Door"
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-Door"
|
||||
self._attr_name = f"{appliance.info.name} Door"
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
@@ -234,6 +221,7 @@ class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Call when entity will be removed from hass."""
|
||||
await super().async_will_remove_from_hass()
|
||||
async_delete_issue(
|
||||
self.hass, DOMAIN, f"deprecated_binary_common_door_sensor_{self.entity_id}"
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Constants for the Home Connect integration."""
|
||||
|
||||
from aiohomeconnect.model import EventKey, SettingKey, StatusKey
|
||||
|
||||
DOMAIN = "home_connect"
|
||||
|
||||
OAUTH2_AUTHORIZE = "https://api.home-connect.com/security/oauth/authorize"
|
||||
OAUTH2_TOKEN = "https://api.home-connect.com/security/oauth/token"
|
||||
|
||||
APPLIANCES_WITH_PROGRAMS = (
|
||||
"CleaningRobot",
|
||||
@@ -17,93 +17,35 @@ APPLIANCES_WITH_PROGRAMS = (
|
||||
"WasherDryer",
|
||||
)
|
||||
|
||||
BSH_POWER_STATE = "BSH.Common.Setting.PowerState"
|
||||
|
||||
BSH_POWER_ON = "BSH.Common.EnumType.PowerState.On"
|
||||
BSH_POWER_OFF = "BSH.Common.EnumType.PowerState.Off"
|
||||
BSH_POWER_STANDBY = "BSH.Common.EnumType.PowerState.Standby"
|
||||
BSH_SELECTED_PROGRAM = "BSH.Common.Root.SelectedProgram"
|
||||
BSH_ACTIVE_PROGRAM = "BSH.Common.Root.ActiveProgram"
|
||||
BSH_REMOTE_CONTROL_ACTIVATION_STATE = "BSH.Common.Status.RemoteControlActive"
|
||||
BSH_REMOTE_START_ALLOWANCE_STATE = "BSH.Common.Status.RemoteControlStartAllowed"
|
||||
BSH_CHILD_LOCK_STATE = "BSH.Common.Setting.ChildLock"
|
||||
|
||||
BSH_REMAINING_PROGRAM_TIME = "BSH.Common.Option.RemainingProgramTime"
|
||||
BSH_COMMON_OPTION_DURATION = "BSH.Common.Option.Duration"
|
||||
BSH_COMMON_OPTION_PROGRAM_PROGRESS = "BSH.Common.Option.ProgramProgress"
|
||||
|
||||
BSH_EVENT_PRESENT_STATE_PRESENT = "BSH.Common.EnumType.EventPresentState.Present"
|
||||
BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confirmed"
|
||||
BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off"
|
||||
|
||||
BSH_OPERATION_STATE = "BSH.Common.Status.OperationState"
|
||||
|
||||
BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run"
|
||||
BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause"
|
||||
BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished"
|
||||
|
||||
COOKING_LIGHTING = "Cooking.Common.Setting.Lighting"
|
||||
COOKING_LIGHTING_BRIGHTNESS = "Cooking.Common.Setting.LightingBrightness"
|
||||
|
||||
COFFEE_EVENT_BEAN_CONTAINER_EMPTY = (
|
||||
"ConsumerProducts.CoffeeMaker.Event.BeanContainerEmpty"
|
||||
)
|
||||
COFFEE_EVENT_WATER_TANK_EMPTY = "ConsumerProducts.CoffeeMaker.Event.WaterTankEmpty"
|
||||
COFFEE_EVENT_DRIP_TRAY_FULL = "ConsumerProducts.CoffeeMaker.Event.DripTrayFull"
|
||||
|
||||
DISHWASHER_EVENT_SALT_NEARLY_EMPTY = "Dishcare.Dishwasher.Event.SaltNearlyEmpty"
|
||||
DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY = (
|
||||
"Dishcare.Dishwasher.Event.RinseAidNearlyEmpty"
|
||||
)
|
||||
|
||||
REFRIGERATION_INTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.Internal.Power"
|
||||
REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS = (
|
||||
"Refrigeration.Common.Setting.Light.Internal.Brightness"
|
||||
)
|
||||
REFRIGERATION_EXTERNAL_LIGHT_POWER = "Refrigeration.Common.Setting.Light.External.Power"
|
||||
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS = (
|
||||
"Refrigeration.Common.Setting.Light.External.Brightness"
|
||||
)
|
||||
|
||||
REFRIGERATION_SUPERMODEFREEZER = "Refrigeration.FridgeFreezer.Setting.SuperModeFreezer"
|
||||
REFRIGERATION_SUPERMODEREFRIGERATOR = (
|
||||
"Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator"
|
||||
)
|
||||
REFRIGERATION_DISPENSER = "Refrigeration.Common.Setting.Dispenser.Enabled"
|
||||
|
||||
REFRIGERATION_STATUS_DOOR_CHILLER = "Refrigeration.Common.Status.Door.ChillerCommon"
|
||||
REFRIGERATION_STATUS_DOOR_FREEZER = "Refrigeration.Common.Status.Door.Freezer"
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR = "Refrigeration.Common.Status.Door.Refrigerator"
|
||||
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED = "Refrigeration.Common.EnumType.Door.States.Closed"
|
||||
REFRIGERATION_STATUS_DOOR_OPEN = "Refrigeration.Common.EnumType.Door.States.Open"
|
||||
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR = (
|
||||
"Refrigeration.FridgeFreezer.Event.DoorAlarmRefrigerator"
|
||||
)
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER = (
|
||||
"Refrigeration.FridgeFreezer.Event.DoorAlarmFreezer"
|
||||
)
|
||||
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER = (
|
||||
"Refrigeration.FridgeFreezer.Event.TemperatureAlarmFreezer"
|
||||
)
|
||||
|
||||
|
||||
BSH_AMBIENT_LIGHT_ENABLED = "BSH.Common.Setting.AmbientLightEnabled"
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS = "BSH.Common.Setting.AmbientLightBrightness"
|
||||
BSH_AMBIENT_LIGHT_COLOR = "BSH.Common.Setting.AmbientLightColor"
|
||||
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR = (
|
||||
"BSH.Common.EnumType.AmbientLightColor.CustomColor"
|
||||
)
|
||||
BSH_AMBIENT_LIGHT_CUSTOM_COLOR = "BSH.Common.Setting.AmbientLightCustomColor"
|
||||
|
||||
BSH_DOOR_STATE = "BSH.Common.Status.DoorState"
|
||||
|
||||
BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed"
|
||||
BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked"
|
||||
BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open"
|
||||
|
||||
BSH_PAUSE = "BSH.Common.Command.PauseProgram"
|
||||
BSH_RESUME = "BSH.Common.Command.ResumeProgram"
|
||||
|
||||
SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities"
|
||||
|
||||
SERVICE_OPTION_ACTIVE = "set_option_active"
|
||||
SERVICE_OPTION_SELECTED = "set_option_selected"
|
||||
@@ -113,51 +55,44 @@ SERVICE_SELECT_PROGRAM = "select_program"
|
||||
SERVICE_SETTING = "change_setting"
|
||||
SERVICE_START_PROGRAM = "start_program"
|
||||
|
||||
ATTR_ALLOWED_VALUES = "allowedvalues"
|
||||
ATTR_AMBIENT = "ambient"
|
||||
ATTR_BSH_KEY = "bsh_key"
|
||||
ATTR_CONSTRAINTS = "constraints"
|
||||
ATTR_DESC = "desc"
|
||||
ATTR_DEVICE = "device"
|
||||
|
||||
ATTR_KEY = "key"
|
||||
ATTR_PROGRAM = "program"
|
||||
ATTR_SENSOR_TYPE = "sensor_type"
|
||||
ATTR_SIGN = "sign"
|
||||
ATTR_STEPSIZE = "stepsize"
|
||||
ATTR_UNIT = "unit"
|
||||
ATTR_VALUE = "value"
|
||||
|
||||
SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
|
||||
|
||||
SVE_TRANSLATION_KEY_SET_SETTING = "set_setting_entity"
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME = "appliance_name"
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID = "entity_id"
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM = "program"
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY = "key"
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE = "value"
|
||||
|
||||
|
||||
OLD_NEW_UNIQUE_ID_SUFFIX_MAP = {
|
||||
"ChildLock": BSH_CHILD_LOCK_STATE,
|
||||
"Operation State": BSH_OPERATION_STATE,
|
||||
"Light": COOKING_LIGHTING,
|
||||
"AmbientLight": BSH_AMBIENT_LIGHT_ENABLED,
|
||||
"Power": BSH_POWER_STATE,
|
||||
"Remaining Program Time": BSH_REMAINING_PROGRAM_TIME,
|
||||
"Duration": BSH_COMMON_OPTION_DURATION,
|
||||
"Program Progress": BSH_COMMON_OPTION_PROGRAM_PROGRESS,
|
||||
"Remote Control": BSH_REMOTE_CONTROL_ACTIVATION_STATE,
|
||||
"Remote Start": BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
"Supermode Freezer": REFRIGERATION_SUPERMODEFREEZER,
|
||||
"Supermode Refrigerator": REFRIGERATION_SUPERMODEREFRIGERATOR,
|
||||
"Dispenser Enabled": REFRIGERATION_DISPENSER,
|
||||
"Internal Light": REFRIGERATION_INTERNAL_LIGHT_POWER,
|
||||
"External Light": REFRIGERATION_EXTERNAL_LIGHT_POWER,
|
||||
"Chiller Door": REFRIGERATION_STATUS_DOOR_CHILLER,
|
||||
"Freezer Door": REFRIGERATION_STATUS_DOOR_FREEZER,
|
||||
"Refrigerator Door": REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
"Door Alarm Freezer": REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
"Door Alarm Refrigerator": REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
|
||||
"Temperature Alarm Freezer": REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
|
||||
"Bean Container Empty": COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
"Water Tank Empty": COFFEE_EVENT_WATER_TANK_EMPTY,
|
||||
"Drip Tray Full": COFFEE_EVENT_DRIP_TRAY_FULL,
|
||||
"ChildLock": SettingKey.BSH_COMMON_CHILD_LOCK,
|
||||
"Operation State": StatusKey.BSH_COMMON_OPERATION_STATE,
|
||||
"Light": SettingKey.COOKING_COMMON_LIGHTING,
|
||||
"AmbientLight": SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED,
|
||||
"Power": SettingKey.BSH_COMMON_POWER_STATE,
|
||||
"Remaining Program Time": EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME,
|
||||
"Duration": EventKey.BSH_COMMON_OPTION_DURATION,
|
||||
"Program Progress": EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
|
||||
"Remote Control": StatusKey.BSH_COMMON_REMOTE_CONTROL_ACTIVE,
|
||||
"Remote Start": StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
|
||||
"Supermode Freezer": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER,
|
||||
"Supermode Refrigerator": SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR,
|
||||
"Dispenser Enabled": SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED,
|
||||
"Internal Light": SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER,
|
||||
"External Light": SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER,
|
||||
"Chiller Door": StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER,
|
||||
"Freezer Door": StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
|
||||
"Refrigerator Door": StatusKey.REFRIGERATION_COMMON_DOOR_REFRIGERATOR,
|
||||
"Door Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
|
||||
"Door Alarm Refrigerator": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
|
||||
"Temperature Alarm Freezer": EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
|
||||
"Bean Container Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
"Water Tank Empty": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
|
||||
"Drip Tray Full": EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Coordinator for Home Connect."""
|
||||
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
GetSetting,
|
||||
HomeAppliance,
|
||||
SettingKey,
|
||||
Status,
|
||||
StatusKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import (
|
||||
EventStreamInterruptedError,
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
HomeConnectRequestError,
|
||||
)
|
||||
from aiohomeconnect.model.program import EnumerateAvailableProgram
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type HomeConnectConfigEntry = ConfigEntry[HomeConnectCoordinator]
|
||||
|
||||
EVENT_STREAM_RECONNECT_DELAY = 30
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectApplianceData:
|
||||
"""Class to hold Home Connect appliance data."""
|
||||
|
||||
events: dict[EventKey, Event] = field(default_factory=dict)
|
||||
info: HomeAppliance
|
||||
programs: list[EnumerateAvailableProgram] = field(default_factory=list)
|
||||
settings: dict[SettingKey, GetSetting]
|
||||
status: dict[StatusKey, Status]
|
||||
|
||||
def update(self, other: "HomeConnectApplianceData") -> None:
|
||||
"""Update data with data from other instance."""
|
||||
self.events.update(other.events)
|
||||
self.info.connected = other.info.connected
|
||||
self.programs.clear()
|
||||
self.programs.extend(other.programs)
|
||||
self.settings.update(other.settings)
|
||||
self.status.update(other.status)
|
||||
|
||||
|
||||
class HomeConnectCoordinator(
|
||||
DataUpdateCoordinator[dict[str, HomeConnectApplianceData]]
|
||||
):
|
||||
"""Class to manage fetching Home Connect data."""
|
||||
|
||||
config_entry: HomeConnectConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomeConnectConfigEntry,
|
||||
client: HomeConnectClient,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=config_entry.entry_id,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
@cached_property
|
||||
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]:
|
||||
"""Return a dict of all listeners registered for a given context."""
|
||||
listeners: dict[tuple[str, EventKey], list[CALLBACK_TYPE]] = defaultdict(list)
|
||||
for listener, context in list(self._listeners.values()):
|
||||
assert isinstance(context, tuple)
|
||||
listeners[context].append(listener)
|
||||
return listeners
|
||||
|
||||
@callback
|
||||
def async_add_listener(
|
||||
self, update_callback: CALLBACK_TYPE, context: Any = None
|
||||
) -> Callable[[], None]:
|
||||
"""Listen for data updates."""
|
||||
remove_listener = super().async_add_listener(update_callback, context)
|
||||
self.__dict__.pop("context_listeners", None)
|
||||
|
||||
def remove_listener_and_invalidate_context_listeners() -> None:
|
||||
remove_listener()
|
||||
self.__dict__.pop("context_listeners", None)
|
||||
|
||||
return remove_listener_and_invalidate_context_listeners
|
||||
|
||||
@callback
|
||||
def start_event_listener(self) -> None:
|
||||
"""Start event listener."""
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._event_listener(),
|
||||
f"home_connect-events_listener_task-{self.config_entry.entry_id}",
|
||||
)
|
||||
|
||||
async def _event_listener(self) -> None:
|
||||
"""Match event with listener for event type."""
|
||||
while True:
|
||||
try:
|
||||
async for event_message in self.client.stream_all_events():
|
||||
match event_message.type:
|
||||
case EventType.STATUS:
|
||||
statuses = self.data[event_message.ha_id].status
|
||||
for event in event_message.data.items:
|
||||
status_key = StatusKey(event.key)
|
||||
if status_key in statuses:
|
||||
statuses[status_key].value = event.value
|
||||
else:
|
||||
statuses[status_key] = Status(
|
||||
key=status_key,
|
||||
raw_key=status_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
|
||||
case EventType.NOTIFY:
|
||||
settings = self.data[event_message.ha_id].settings
|
||||
events = self.data[event_message.ha_id].events
|
||||
for event in event_message.data.items:
|
||||
if event.key in SettingKey:
|
||||
setting_key = SettingKey(event.key)
|
||||
if setting_key in settings:
|
||||
settings[setting_key].value = event.value
|
||||
else:
|
||||
settings[setting_key] = GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value=event.value,
|
||||
)
|
||||
else:
|
||||
events[event.key] = event
|
||||
|
||||
case EventType.EVENT:
|
||||
events = self.data[event_message.ha_id].events
|
||||
for event in event_message.data.items:
|
||||
events[event.key] = event
|
||||
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
except (EventStreamInterruptedError, HomeConnectRequestError) as error:
|
||||
_LOGGER.debug(
|
||||
"Non-breaking error (%s) while listening for events,"
|
||||
" continuing in 30 seconds",
|
||||
type(error).__name__,
|
||||
)
|
||||
await asyncio.sleep(EVENT_STREAM_RECONNECT_DELAY)
|
||||
except HomeConnectApiError as error:
|
||||
_LOGGER.error("Error while listening for events: %s", error)
|
||||
self.hass.config_entries.async_schedule_reload(
|
||||
self.config_entry.entry_id
|
||||
)
|
||||
break
|
||||
# if there was a non-breaking error, we continue listening
|
||||
# but we need to refresh the data to get the possible changes
|
||||
# that happened while the event stream was interrupted
|
||||
await self.async_refresh()
|
||||
|
||||
@callback
|
||||
def _call_event_listener(self, event_message: EventMessage):
|
||||
"""Call listener for event."""
|
||||
for event in event_message.data.items:
|
||||
for listener in self.context_listeners.get(
|
||||
(event_message.ha_id, event.key), []
|
||||
):
|
||||
listener()
|
||||
|
||||
async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]:
|
||||
"""Fetch data from Home Connect."""
|
||||
try:
|
||||
appliances = await self.client.get_home_appliances()
|
||||
except HomeConnectError as error:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="fetch_api_error",
|
||||
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
|
||||
)
|
||||
if appliance.ha_id in appliances_data:
|
||||
appliances_data[appliance.ha_id].update(appliance_data)
|
||||
appliance_data = appliances_data[appliance.ha_id]
|
||||
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_available_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
|
||||
@@ -4,33 +4,25 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeconnect.api import HomeConnectAppliance, HomeConnectError
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
|
||||
from . import HomeConnectConfigEntry, _get_appliance
|
||||
from .api import HomeConnectDevice
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
|
||||
|
||||
def _generate_appliance_diagnostics(appliance: HomeConnectAppliance) -> dict[str, Any]:
|
||||
try:
|
||||
programs = appliance.get_programs_available()
|
||||
except HomeConnectError:
|
||||
programs = None
|
||||
async def _generate_appliance_diagnostics(
|
||||
client: HomeConnectClient, appliance: HomeConnectApplianceData
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"connected": appliance.connected,
|
||||
"status": appliance.status,
|
||||
"programs": programs,
|
||||
}
|
||||
|
||||
|
||||
def _generate_entry_diagnostics(
|
||||
devices: list[HomeConnectDevice],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
return {
|
||||
device.appliance.haId: _generate_appliance_diagnostics(device.appliance)
|
||||
for device in devices
|
||||
**appliance.info.to_dict(),
|
||||
"status": {key.value: status.value for key, status in appliance.status.items()},
|
||||
"settings": {
|
||||
key.value: setting.value for key, setting in appliance.settings.items()
|
||||
},
|
||||
"programs": [program.raw_key for program in appliance.programs],
|
||||
}
|
||||
|
||||
|
||||
@@ -38,14 +30,21 @@ async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, entry: HomeConnectConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
return await hass.async_add_executor_job(
|
||||
_generate_entry_diagnostics, entry.runtime_data.devices
|
||||
)
|
||||
return {
|
||||
appliance.info.ha_id: await _generate_appliance_diagnostics(
|
||||
entry.runtime_data.client, appliance
|
||||
)
|
||||
for appliance in entry.runtime_data.data.values()
|
||||
}
|
||||
|
||||
|
||||
async def async_get_device_diagnostics(
|
||||
hass: HomeAssistant, entry: HomeConnectConfigEntry, device: DeviceEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a device."""
|
||||
appliance = _get_appliance(hass, device_entry=device, entry=entry)
|
||||
return await hass.async_add_executor_job(_generate_appliance_diagnostics, appliance)
|
||||
ha_id = next(
|
||||
(identifier[1] for identifier in device.identifiers if identifier[0] == DOMAIN),
|
||||
)
|
||||
return await _generate_appliance_diagnostics(
|
||||
entry.runtime_data.client, entry.runtime_data.data[ha_id]
|
||||
)
|
||||
|
||||
@@ -1,55 +1,56 @@
|
||||
"""Home Connect entity base class."""
|
||||
|
||||
from abc import abstractmethod
|
||||
import logging
|
||||
|
||||
from aiohomeconnect.model import EventKey
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .api import HomeConnectDevice
|
||||
from .const import DOMAIN, SIGNAL_UPDATE_ENTITIES
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeConnectEntity(Entity):
|
||||
class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
"""Generic Home Connect entity (base class)."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, device: HomeConnectDevice, desc: EntityDescription) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
desc: EntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self.device = device
|
||||
super().__init__(coordinator, (appliance.info.ha_id, EventKey(desc.key)))
|
||||
self.appliance = appliance
|
||||
self.entity_description = desc
|
||||
self._attr_unique_id = f"{device.appliance.haId}-{self.bsh_key}"
|
||||
self._attr_unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, device.appliance.haId)},
|
||||
manufacturer=device.appliance.brand,
|
||||
model=device.appliance.vib,
|
||||
name=device.appliance.name,
|
||||
identifiers={(DOMAIN, appliance.info.ha_id)},
|
||||
manufacturer=appliance.info.brand,
|
||||
model=appliance.info.vib,
|
||||
name=appliance.info.name,
|
||||
)
|
||||
self.update_native_value()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_UPDATE_ENTITIES, self._update_callback
|
||||
)
|
||||
)
|
||||
@abstractmethod
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
|
||||
@callback
|
||||
def _update_callback(self, ha_id: str) -> None:
|
||||
"""Update data."""
|
||||
if ha_id == self.device.appliance.haId:
|
||||
self.async_entity_update()
|
||||
|
||||
@callback
|
||||
def async_entity_update(self) -> None:
|
||||
"""Update the entity."""
|
||||
_LOGGER.debug("Entity update triggered on %s", self)
|
||||
self.async_schedule_update_ha_state(True)
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self.update_native_value()
|
||||
self.async_write_ha_state()
|
||||
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state)
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> str:
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import EventKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
@@ -20,25 +20,18 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
|
||||
from .api import HomeConnectDevice
|
||||
from .const import (
|
||||
ATTR_VALUE,
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS,
|
||||
BSH_AMBIENT_LIGHT_COLOR,
|
||||
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
BSH_AMBIENT_LIGHT_CUSTOM_COLOR,
|
||||
BSH_AMBIENT_LIGHT_ENABLED,
|
||||
COOKING_LIGHTING,
|
||||
COOKING_LIGHTING_BRIGHTNESS,
|
||||
DOMAIN,
|
||||
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS,
|
||||
REFRIGERATION_EXTERNAL_LIGHT_POWER,
|
||||
REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS,
|
||||
REFRIGERATION_INTERNAL_LIGHT_POWER,
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
|
||||
)
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -47,38 +40,38 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class HomeConnectLightEntityDescription(LightEntityDescription):
|
||||
"""Light entity description."""
|
||||
|
||||
brightness_key: str | None = None
|
||||
color_key: str | None = None
|
||||
brightness_key: SettingKey | None = None
|
||||
color_key: SettingKey | None = None
|
||||
enable_custom_color_value_key: str | None = None
|
||||
custom_color_key: str | None = None
|
||||
custom_color_key: SettingKey | None = None
|
||||
brightness_scale: tuple[float, float] = (0.0, 100.0)
|
||||
|
||||
|
||||
LIGHTS: tuple[HomeConnectLightEntityDescription, ...] = (
|
||||
HomeConnectLightEntityDescription(
|
||||
key=REFRIGERATION_INTERNAL_LIGHT_POWER,
|
||||
brightness_key=REFRIGERATION_INTERNAL_LIGHT_BRIGHTNESS,
|
||||
key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_POWER,
|
||||
brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_INTERNAL_BRIGHTNESS,
|
||||
brightness_scale=(1.0, 100.0),
|
||||
translation_key="internal_light",
|
||||
),
|
||||
HomeConnectLightEntityDescription(
|
||||
key=REFRIGERATION_EXTERNAL_LIGHT_POWER,
|
||||
brightness_key=REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS,
|
||||
key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER,
|
||||
brightness_key=SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_BRIGHTNESS,
|
||||
brightness_scale=(1.0, 100.0),
|
||||
translation_key="external_light",
|
||||
),
|
||||
HomeConnectLightEntityDescription(
|
||||
key=COOKING_LIGHTING,
|
||||
brightness_key=COOKING_LIGHTING_BRIGHTNESS,
|
||||
key=SettingKey.COOKING_COMMON_LIGHTING,
|
||||
brightness_key=SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS,
|
||||
brightness_scale=(10.0, 100.0),
|
||||
translation_key="cooking_lighting",
|
||||
),
|
||||
HomeConnectLightEntityDescription(
|
||||
key=BSH_AMBIENT_LIGHT_ENABLED,
|
||||
brightness_key=BSH_AMBIENT_LIGHT_BRIGHTNESS,
|
||||
color_key=BSH_AMBIENT_LIGHT_COLOR,
|
||||
key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED,
|
||||
brightness_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS,
|
||||
color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
|
||||
enable_custom_color_value_key=BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
custom_color_key=BSH_AMBIENT_LIGHT_CUSTOM_COLOR,
|
||||
custom_color_key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR,
|
||||
brightness_scale=(10.0, 100.0),
|
||||
translation_key="ambient_light",
|
||||
),
|
||||
@@ -92,16 +85,14 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect light."""
|
||||
|
||||
def get_entities() -> list[LightEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectLight(device, description)
|
||||
async_add_entities(
|
||||
[
|
||||
HomeConnectLight(entry.runtime_data, appliance, description)
|
||||
for description in LIGHTS
|
||||
for device in entry.runtime_data.devices
|
||||
if description.key in device.appliance.status
|
||||
]
|
||||
|
||||
async_add_entities(await hass.async_add_executor_job(get_entities), True)
|
||||
for appliance in entry.runtime_data.data.values()
|
||||
if description.key in appliance.settings
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
@@ -110,13 +101,17 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
entity_description: LightEntityDescription
|
||||
|
||||
def __init__(
|
||||
self, device: HomeConnectDevice, desc: HomeConnectLightEntityDescription
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
desc: HomeConnectLightEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(device, desc)
|
||||
|
||||
def get_setting_key_if_setting_exists(setting_key: str | None) -> str | None:
|
||||
if setting_key and setting_key in device.appliance.status:
|
||||
def get_setting_key_if_setting_exists(
|
||||
setting_key: SettingKey | None,
|
||||
) -> SettingKey | None:
|
||||
if setting_key and setting_key in appliance.settings:
|
||||
return setting_key
|
||||
return None
|
||||
|
||||
@@ -131,6 +126,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
)
|
||||
self._brightness_scale = desc.brightness_scale
|
||||
|
||||
super().__init__(coordinator, appliance, desc)
|
||||
|
||||
match (self._brightness_key, self._custom_color_key):
|
||||
case (None, None):
|
||||
self._attr_color_mode = ColorMode.ONOFF
|
||||
@@ -144,10 +141,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the light on, change brightness, change color."""
|
||||
_LOGGER.debug("Switching light on for: %s", self.name)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting, self.bsh_key, True
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey(self.bsh_key),
|
||||
value=True,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -158,15 +156,15 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
|
||||
},
|
||||
) from err
|
||||
if self._custom_color_key:
|
||||
if self._color_key and self._custom_color_key:
|
||||
if (
|
||||
ATTR_RGB_COLOR in kwargs or ATTR_HS_COLOR in kwargs
|
||||
) and self._enable_custom_color_value_key:
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting,
|
||||
self._color_key,
|
||||
self._enable_custom_color_value_key,
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=self._color_key,
|
||||
value=self._enable_custom_color_value_key,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -181,10 +179,10 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
hex_val = color_util.color_rgb_to_hex(*kwargs[ATTR_RGB_COLOR])
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting,
|
||||
self._custom_color_key,
|
||||
f"#{hex_val}",
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=self._custom_color_key,
|
||||
value=f"#{hex_val}",
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -195,10 +193,11 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
|
||||
},
|
||||
) from err
|
||||
elif (ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs) and (
|
||||
self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs
|
||||
return
|
||||
if (self._attr_brightness is not None or ATTR_BRIGHTNESS in kwargs) and (
|
||||
self._attr_hs_color is not None or ATTR_HS_COLOR in kwargs
|
||||
):
|
||||
brightness = 10 + ceil(
|
||||
brightness = round(
|
||||
color_util.brightness_to_value(
|
||||
self._brightness_scale,
|
||||
kwargs.get(ATTR_BRIGHTNESS, self._attr_brightness),
|
||||
@@ -207,41 +206,36 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
|
||||
hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color)
|
||||
|
||||
if hs_color is not None:
|
||||
rgb = color_util.color_hsv_to_RGB(
|
||||
hs_color[0], hs_color[1], brightness
|
||||
rgb = color_util.color_hsv_to_RGB(hs_color[0], hs_color[1], brightness)
|
||||
hex_val = color_util.color_rgb_to_hex(*rgb)
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=self._custom_color_key,
|
||||
value=f"#{hex_val}",
|
||||
)
|
||||
hex_val = color_util.color_rgb_to_hex(*rgb)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting,
|
||||
self._custom_color_key,
|
||||
f"#{hex_val}",
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_light_color",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
|
||||
},
|
||||
) from err
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_light_color",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
|
||||
},
|
||||
) from err
|
||||
return
|
||||
|
||||
elif self._brightness_key and ATTR_BRIGHTNESS in kwargs:
|
||||
_LOGGER.debug(
|
||||
"Changing brightness for: %s, to: %s",
|
||||
self.name,
|
||||
kwargs[ATTR_BRIGHTNESS],
|
||||
)
|
||||
brightness = ceil(
|
||||
if self._brightness_key and ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = round(
|
||||
color_util.brightness_to_value(
|
||||
self._brightness_scale, kwargs[ATTR_BRIGHTNESS]
|
||||
)
|
||||
)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting, self._brightness_key, brightness
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=self._brightness_key,
|
||||
value=brightness,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -253,14 +247,13 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the light off."""
|
||||
_LOGGER.debug("Switching light off for: %s", self.name)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting, self.bsh_key, False
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey(self.bsh_key),
|
||||
value=False,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -271,30 +264,50 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
|
||||
},
|
||||
) from err
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register listener."""
|
||||
await super().async_added_to_hass()
|
||||
keys_to_listen = []
|
||||
if self._brightness_key:
|
||||
keys_to_listen.append(self._brightness_key)
|
||||
if self._color_key and self._custom_color_key:
|
||||
keys_to_listen.extend([self._color_key, self._custom_color_key])
|
||||
for key in keys_to_listen:
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_coordinator_update,
|
||||
(
|
||||
self.appliance.info.ha_id,
|
||||
EventKey(key),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the light's status."""
|
||||
if self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is True:
|
||||
self._attr_is_on = True
|
||||
elif (
|
||||
self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE) is False
|
||||
):
|
||||
self._attr_is_on = False
|
||||
else:
|
||||
self._attr_is_on = None
|
||||
self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value
|
||||
|
||||
_LOGGER.debug("Updated, new light state: %s", self._attr_is_on)
|
||||
|
||||
if self._custom_color_key:
|
||||
color = self.device.appliance.status.get(self._custom_color_key, {})
|
||||
|
||||
if not color:
|
||||
if self._brightness_key:
|
||||
brightness = cast(
|
||||
float, self.appliance.settings[self._brightness_key].value
|
||||
)
|
||||
self._attr_brightness = color_util.value_to_brightness(
|
||||
self._brightness_scale, brightness
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s, new brightness: %s", self.entity_id, self._attr_brightness
|
||||
)
|
||||
if self._color_key and self._custom_color_key:
|
||||
color = cast(str, self.appliance.settings[self._color_key].value)
|
||||
if color != self._enable_custom_color_value_key:
|
||||
self._attr_rgb_color = None
|
||||
self._attr_hs_color = None
|
||||
self._attr_brightness = None
|
||||
else:
|
||||
color_value = color.get(ATTR_VALUE)[1:]
|
||||
custom_color = cast(
|
||||
str, self.appliance.settings[self._custom_color_key].value
|
||||
)
|
||||
color_value = custom_color[1:]
|
||||
rgb = color_util.rgb_hex_to_rgb_list(color_value)
|
||||
self._attr_rgb_color = (rgb[0], rgb[1], rgb[2])
|
||||
hsv = color_util.color_RGB_to_hsv(*rgb)
|
||||
@@ -303,16 +316,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity):
|
||||
self._brightness_scale, hsv[2]
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated, new color (%s) and new brightness (%s) ",
|
||||
"Updated %s, new color (%s) and new brightness (%s) ",
|
||||
self.entity_id,
|
||||
color_value,
|
||||
self._attr_brightness,
|
||||
)
|
||||
elif self._brightness_key:
|
||||
brightness = self.device.appliance.status.get(self._brightness_key, {})
|
||||
if brightness is None:
|
||||
self._attr_brightness = None
|
||||
else:
|
||||
self._attr_brightness = color_util.value_to_brightness(
|
||||
self._brightness_scale, brightness[ATTR_VALUE]
|
||||
)
|
||||
_LOGGER.debug("Updated, new brightness: %s", self._attr_brightness)
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/home_connect",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["homeconnect"],
|
||||
"requirements": ["homeconnect==0.8.0"]
|
||||
"requirements": ["aiohomeconnect==0.12.1"]
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Provides number enties for Home Connect."""
|
||||
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import GetSetting, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.number import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
@@ -15,66 +15,63 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
|
||||
from .const import (
|
||||
ATTR_CONSTRAINTS,
|
||||
ATTR_STEPSIZE,
|
||||
ATTR_UNIT,
|
||||
ATTR_VALUE,
|
||||
DOMAIN,
|
||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
)
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NUMBERS = (
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator",
|
||||
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="refrigerator_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.FridgeFreezer.Setting.SetpointTemperatureFreezer",
|
||||
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_FREEZER,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="freezer_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.BottleCooler.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_BOTTLE_COOLER_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="bottle_cooler_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.ChillerLeft.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_CHILLER_LEFT_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="chiller_left_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.ChillerCommon.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_CHILLER_COMMON_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="chiller_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.ChillerRight.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_CHILLER_RIGHT_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="chiller_right_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.WineCompartment.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="wine_compartment_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.WineCompartment2.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_2_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="wine_compartment_2_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key="Refrigeration.Common.Setting.WineCompartment3.SetpointTemperature",
|
||||
key=SettingKey.REFRIGERATION_COMMON_WINE_COMPARTMENT_3_SETPOINT_TEMPERATURE,
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="wine_compartment_3_setpoint_temperature",
|
||||
),
|
||||
@@ -87,17 +84,14 @@ async def async_setup_entry(
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect number."""
|
||||
|
||||
def get_entities() -> list[HomeConnectNumberEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectNumberEntity(device, description)
|
||||
async_add_entities(
|
||||
[
|
||||
HomeConnectNumberEntity(entry.runtime_data, appliance, description)
|
||||
for description in NUMBERS
|
||||
for device in entry.runtime_data.devices
|
||||
if description.key in device.appliance.status
|
||||
]
|
||||
|
||||
async_add_entities(await hass.async_add_executor_job(get_entities), True)
|
||||
for appliance in entry.runtime_data.data.values()
|
||||
if description.key in appliance.settings
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
@@ -112,10 +106,10 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
self.entity_id,
|
||||
)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting,
|
||||
self.bsh_key,
|
||||
value,
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey(self.bsh_key),
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -132,34 +126,41 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
async def async_fetch_constraints(self) -> None:
|
||||
"""Fetch the max and min values and step for the number entity."""
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(
|
||||
self.device.appliance.get, f"/settings/{self.bsh_key}"
|
||||
data = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id, setting_key=SettingKey(self.bsh_key)
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error("An error occurred: %s", err)
|
||||
return
|
||||
if not data or not (constraints := data.get(ATTR_CONSTRAINTS)):
|
||||
return
|
||||
self._attr_native_max_value = constraints.get(ATTR_MAX)
|
||||
self._attr_native_min_value = constraints.get(ATTR_MIN)
|
||||
self._attr_native_step = constraints.get(ATTR_STEPSIZE)
|
||||
self._attr_native_unit_of_measurement = data.get(ATTR_UNIT)
|
||||
else:
|
||||
self.set_constraints(data)
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the number setting status."""
|
||||
if not (data := self.device.appliance.status.get(self.bsh_key)):
|
||||
_LOGGER.error("No value for %s", self.bsh_key)
|
||||
self._attr_native_value = None
|
||||
def set_constraints(self, setting: GetSetting) -> None:
|
||||
"""Set constraints for the number entity."""
|
||||
if not (constraints := setting.constraints):
|
||||
return
|
||||
self._attr_native_value = data.get(ATTR_VALUE, None)
|
||||
_LOGGER.debug("Updated, new value: %s", self._attr_native_value)
|
||||
if constraints.max:
|
||||
self._attr_native_max_value = constraints.max
|
||||
if constraints.min:
|
||||
self._attr_native_min_value = constraints.min
|
||||
if constraints.step_size:
|
||||
self._attr_native_step = constraints.step_size
|
||||
else:
|
||||
self._attr_native_step = 0.1 if setting.type == "Double" else 1
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Update status when an event for the entity is received."""
|
||||
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
|
||||
self._attr_native_value = cast(float, data.value)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
|
||||
self._attr_native_unit_of_measurement = data.unit
|
||||
self.set_constraints(data)
|
||||
if (
|
||||
not hasattr(self, "_attr_native_min_value")
|
||||
or self._attr_native_min_value is None
|
||||
or not hasattr(self, "_attr_native_max_value")
|
||||
or self._attr_native_max_value is None
|
||||
or not hasattr(self, "_attr_native_step")
|
||||
or self._attr_native_step is None
|
||||
):
|
||||
await self.async_fetch_constraints()
|
||||
|
||||
@@ -1,191 +1,28 @@
|
||||
"""Provides a select platform for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import EventKey, ProgramKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import (
|
||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN, SVE_TRANSLATION_PLACEHOLDER_PROGRAM
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
bsh_key_to_translation_key,
|
||||
get_dict_from_home_connect_error,
|
||||
)
|
||||
from .api import HomeConnectDevice
|
||||
from .const import (
|
||||
APPLIANCES_WITH_PROGRAMS,
|
||||
ATTR_VALUE,
|
||||
BSH_ACTIVE_PROGRAM,
|
||||
BSH_SELECTED_PROGRAM,
|
||||
DOMAIN,
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP = {
|
||||
bsh_key_to_translation_key(program): program
|
||||
for program in (
|
||||
"ConsumerProducts.CleaningRobot.Program.Cleaning.CleanAll",
|
||||
"ConsumerProducts.CleaningRobot.Program.Cleaning.CleanMap",
|
||||
"ConsumerProducts.CleaningRobot.Program.Basic.GoHome",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.Ristretto",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoDoppio",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.Coffee",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.XLCoffee",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeGrande",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.Cappuccino",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.MilkFroth",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.WarmMilk",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KleinerBrauner",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.GrosserBrauner",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Verlaengerter",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.VerlaengerterBraun",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.WienerMelange",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.FlatWhite",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Cortado",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeCortado",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeConLeche",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.CafeAuLait",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Doppio",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Kaapi",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.KoffieVerkeerd",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Galao",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Garoto",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.Americano",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.RedEye",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.BlackEye",
|
||||
"ConsumerProducts.CoffeeMaker.Program.CoffeeWorld.DeadEye",
|
||||
"ConsumerProducts.CoffeeMaker.Program.Beverage.HotWater",
|
||||
"Dishcare.Dishwasher.Program.PreRinse",
|
||||
"Dishcare.Dishwasher.Program.Auto1",
|
||||
"Dishcare.Dishwasher.Program.Auto2",
|
||||
"Dishcare.Dishwasher.Program.Auto3",
|
||||
"Dishcare.Dishwasher.Program.Eco50",
|
||||
"Dishcare.Dishwasher.Program.Quick45",
|
||||
"Dishcare.Dishwasher.Program.Intensiv70",
|
||||
"Dishcare.Dishwasher.Program.Normal65",
|
||||
"Dishcare.Dishwasher.Program.Glas40",
|
||||
"Dishcare.Dishwasher.Program.GlassCare",
|
||||
"Dishcare.Dishwasher.Program.NightWash",
|
||||
"Dishcare.Dishwasher.Program.Quick65",
|
||||
"Dishcare.Dishwasher.Program.Normal45",
|
||||
"Dishcare.Dishwasher.Program.Intensiv45",
|
||||
"Dishcare.Dishwasher.Program.AutoHalfLoad",
|
||||
"Dishcare.Dishwasher.Program.IntensivPower",
|
||||
"Dishcare.Dishwasher.Program.MagicDaily",
|
||||
"Dishcare.Dishwasher.Program.Super60",
|
||||
"Dishcare.Dishwasher.Program.Kurz60",
|
||||
"Dishcare.Dishwasher.Program.ExpressSparkle65",
|
||||
"Dishcare.Dishwasher.Program.MachineCare",
|
||||
"Dishcare.Dishwasher.Program.SteamFresh",
|
||||
"Dishcare.Dishwasher.Program.MaximumCleaning",
|
||||
"Dishcare.Dishwasher.Program.MixedLoad",
|
||||
"LaundryCare.Dryer.Program.Cotton",
|
||||
"LaundryCare.Dryer.Program.Synthetic",
|
||||
"LaundryCare.Dryer.Program.Mix",
|
||||
"LaundryCare.Dryer.Program.Blankets",
|
||||
"LaundryCare.Dryer.Program.BusinessShirts",
|
||||
"LaundryCare.Dryer.Program.DownFeathers",
|
||||
"LaundryCare.Dryer.Program.Hygiene",
|
||||
"LaundryCare.Dryer.Program.Jeans",
|
||||
"LaundryCare.Dryer.Program.Outdoor",
|
||||
"LaundryCare.Dryer.Program.SyntheticRefresh",
|
||||
"LaundryCare.Dryer.Program.Towels",
|
||||
"LaundryCare.Dryer.Program.Delicates",
|
||||
"LaundryCare.Dryer.Program.Super40",
|
||||
"LaundryCare.Dryer.Program.Shirts15",
|
||||
"LaundryCare.Dryer.Program.Pillow",
|
||||
"LaundryCare.Dryer.Program.AntiShrink",
|
||||
"LaundryCare.Dryer.Program.MyTime.MyDryingTime",
|
||||
"LaundryCare.Dryer.Program.TimeCold",
|
||||
"LaundryCare.Dryer.Program.TimeWarm",
|
||||
"LaundryCare.Dryer.Program.InBasket",
|
||||
"LaundryCare.Dryer.Program.TimeColdFix.TimeCold20",
|
||||
"LaundryCare.Dryer.Program.TimeColdFix.TimeCold30",
|
||||
"LaundryCare.Dryer.Program.TimeColdFix.TimeCold60",
|
||||
"LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm30",
|
||||
"LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm40",
|
||||
"LaundryCare.Dryer.Program.TimeWarmFix.TimeWarm60",
|
||||
"LaundryCare.Dryer.Program.Dessous",
|
||||
"Cooking.Common.Program.Hood.Automatic",
|
||||
"Cooking.Common.Program.Hood.Venting",
|
||||
"Cooking.Common.Program.Hood.DelayedShutOff",
|
||||
"Cooking.Oven.Program.HeatingMode.PreHeating",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAir",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAirEco",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAirGrilling",
|
||||
"Cooking.Oven.Program.HeatingMode.TopBottomHeating",
|
||||
"Cooking.Oven.Program.HeatingMode.TopBottomHeatingEco",
|
||||
"Cooking.Oven.Program.HeatingMode.BottomHeating",
|
||||
"Cooking.Oven.Program.HeatingMode.PizzaSetting",
|
||||
"Cooking.Oven.Program.HeatingMode.SlowCook",
|
||||
"Cooking.Oven.Program.HeatingMode.IntensiveHeat",
|
||||
"Cooking.Oven.Program.HeatingMode.KeepWarm",
|
||||
"Cooking.Oven.Program.HeatingMode.PreheatOvenware",
|
||||
"Cooking.Oven.Program.HeatingMode.FrozenHeatupSpecial",
|
||||
"Cooking.Oven.Program.HeatingMode.Desiccation",
|
||||
"Cooking.Oven.Program.HeatingMode.Defrost",
|
||||
"Cooking.Oven.Program.HeatingMode.Proof",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAir30Steam",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAir60Steam",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAir80Steam",
|
||||
"Cooking.Oven.Program.HeatingMode.HotAir100Steam",
|
||||
"Cooking.Oven.Program.HeatingMode.SabbathProgramme",
|
||||
"Cooking.Oven.Program.Microwave.90Watt",
|
||||
"Cooking.Oven.Program.Microwave.180Watt",
|
||||
"Cooking.Oven.Program.Microwave.360Watt",
|
||||
"Cooking.Oven.Program.Microwave.600Watt",
|
||||
"Cooking.Oven.Program.Microwave.900Watt",
|
||||
"Cooking.Oven.Program.Microwave.1000Watt",
|
||||
"Cooking.Oven.Program.Microwave.Max",
|
||||
"Cooking.Oven.Program.HeatingMode.WarmingDrawer",
|
||||
"LaundryCare.Washer.Program.Cotton",
|
||||
"LaundryCare.Washer.Program.Cotton.CottonEco",
|
||||
"LaundryCare.Washer.Program.Cotton.Eco4060",
|
||||
"LaundryCare.Washer.Program.Cotton.Colour",
|
||||
"LaundryCare.Washer.Program.EasyCare",
|
||||
"LaundryCare.Washer.Program.Mix",
|
||||
"LaundryCare.Washer.Program.Mix.NightWash",
|
||||
"LaundryCare.Washer.Program.DelicatesSilk",
|
||||
"LaundryCare.Washer.Program.Wool",
|
||||
"LaundryCare.Washer.Program.Sensitive",
|
||||
"LaundryCare.Washer.Program.Auto30",
|
||||
"LaundryCare.Washer.Program.Auto40",
|
||||
"LaundryCare.Washer.Program.Auto60",
|
||||
"LaundryCare.Washer.Program.Chiffon",
|
||||
"LaundryCare.Washer.Program.Curtains",
|
||||
"LaundryCare.Washer.Program.DarkWash",
|
||||
"LaundryCare.Washer.Program.Dessous",
|
||||
"LaundryCare.Washer.Program.Monsoon",
|
||||
"LaundryCare.Washer.Program.Outdoor",
|
||||
"LaundryCare.Washer.Program.PlushToy",
|
||||
"LaundryCare.Washer.Program.ShirtsBlouses",
|
||||
"LaundryCare.Washer.Program.SportFitness",
|
||||
"LaundryCare.Washer.Program.Towels",
|
||||
"LaundryCare.Washer.Program.WaterProof",
|
||||
"LaundryCare.Washer.Program.PowerSpeed59",
|
||||
"LaundryCare.Washer.Program.Super153045.Super15",
|
||||
"LaundryCare.Washer.Program.Super153045.Super1530",
|
||||
"LaundryCare.Washer.Program.DownDuvet.Duvet",
|
||||
"LaundryCare.Washer.Program.Rinse.RinseSpinDrain",
|
||||
"LaundryCare.Washer.Program.DrumClean",
|
||||
"LaundryCare.WasherDryer.Program.Cotton",
|
||||
"LaundryCare.WasherDryer.Program.Cotton.Eco4060",
|
||||
"LaundryCare.WasherDryer.Program.Mix",
|
||||
"LaundryCare.WasherDryer.Program.EasyCare",
|
||||
"LaundryCare.WasherDryer.Program.WashAndDry60",
|
||||
"LaundryCare.WasherDryer.Program.WashAndDry90",
|
||||
)
|
||||
bsh_key_to_translation_key(program.value): cast(ProgramKey, program)
|
||||
for program in ProgramKey
|
||||
if program != ProgramKey.UNKNOWN
|
||||
}
|
||||
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP = {
|
||||
@@ -194,11 +31,11 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
|
||||
|
||||
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
|
||||
SelectEntityDescription(
|
||||
key=BSH_ACTIVE_PROGRAM,
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
translation_key="active_program",
|
||||
),
|
||||
SelectEntityDescription(
|
||||
key=BSH_SELECTED_PROGRAM,
|
||||
key=EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
translation_key="selected_program",
|
||||
),
|
||||
)
|
||||
@@ -211,31 +48,12 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect select entities."""
|
||||
|
||||
def get_entities() -> list[HomeConnectProgramSelectEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[HomeConnectProgramSelectEntity] = []
|
||||
programs_not_found = set()
|
||||
for device in entry.runtime_data.devices:
|
||||
if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
programs = device.appliance.get_programs_available()
|
||||
if programs:
|
||||
for program in programs.copy():
|
||||
if program not in PROGRAMS_TRANSLATION_KEYS_MAP:
|
||||
programs.remove(program)
|
||||
if program not in programs_not_found:
|
||||
_LOGGER.info(
|
||||
'The program "%s" is not part of the official Home Connect API specification',
|
||||
program,
|
||||
)
|
||||
programs_not_found.add(program)
|
||||
entities.extend(
|
||||
HomeConnectProgramSelectEntity(device, programs, desc)
|
||||
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
||||
)
|
||||
return entities
|
||||
|
||||
async_add_entities(await hass.async_add_executor_job(get_entities), True)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
@@ -243,48 +61,45 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: HomeConnectDevice,
|
||||
programs: list[str],
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
desc: SelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
device,
|
||||
coordinator,
|
||||
appliance,
|
||||
desc,
|
||||
)
|
||||
self._attr_options = [
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP[program] for program in programs
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP[program.key]
|
||||
for program in appliance.programs
|
||||
if program.key != ProgramKey.UNKNOWN
|
||||
]
|
||||
self.start_on_select = desc.key == BSH_ACTIVE_PROGRAM
|
||||
self.start_on_select = desc.key == EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM
|
||||
self._attr_current_option = None
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the program selection status."""
|
||||
program = self.device.appliance.status.get(self.bsh_key, {}).get(ATTR_VALUE)
|
||||
if not program:
|
||||
program_translation_key = None
|
||||
elif not (
|
||||
program_translation_key := PROGRAMS_TRANSLATION_KEYS_MAP.get(program)
|
||||
):
|
||||
_LOGGER.debug(
|
||||
'The program "%s" is not part of the official Home Connect API specification',
|
||||
program,
|
||||
)
|
||||
self._attr_current_option = program_translation_key
|
||||
_LOGGER.debug("Updated, new program: %s", self._attr_current_option)
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the program value."""
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
self._attr_current_option = (
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
|
||||
if event
|
||||
else None
|
||||
)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select new program."""
|
||||
bsh_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
|
||||
_LOGGER.debug(
|
||||
"Starting program: %s" if self.start_on_select else "Selecting program: %s",
|
||||
bsh_key,
|
||||
)
|
||||
if self.start_on_select:
|
||||
target = self.device.appliance.start_program
|
||||
else:
|
||||
target = self.device.appliance.select_program
|
||||
program_key = TRANSLATION_KEYS_PROGRAMS_MAP[option]
|
||||
try:
|
||||
await self.hass.async_add_executor_job(target, bsh_key)
|
||||
if self.start_on_select:
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
else:
|
||||
await self.coordinator.client.set_selected_program(
|
||||
self.appliance.info.ha_id, program_key=program_key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
if self.start_on_select:
|
||||
translation_key = "start_program"
|
||||
@@ -295,7 +110,6 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
translation_key=translation_key,
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: bsh_key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
|
||||
},
|
||||
) from err
|
||||
self.async_entity_update()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Provides a sensor for Home Connect."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, StatusKey
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
@@ -12,38 +13,26 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
|
||||
from . import HomeConnectConfigEntry
|
||||
from .const import (
|
||||
APPLIANCES_WITH_PROGRAMS,
|
||||
ATTR_VALUE,
|
||||
BSH_DOOR_STATE,
|
||||
BSH_OPERATION_STATE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
COFFEE_EVENT_DRIP_TRAY_FULL,
|
||||
COFFEE_EVENT_WATER_TANK_EMPTY,
|
||||
DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
|
||||
DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
|
||||
REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
|
||||
)
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
EVENT_OPTIONS = ["confirmed", "off", "present"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectSensorEntityDescription(SensorEntityDescription):
|
||||
class HomeConnectSensorEntityDescription(
|
||||
SensorEntityDescription,
|
||||
):
|
||||
"""Entity Description class for sensors."""
|
||||
|
||||
default_value: str | None = None
|
||||
@@ -52,7 +41,7 @@ class HomeConnectSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
BSH_PROGRAM_SENSORS = (
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="BSH.Common.Option.RemainingProgramTime",
|
||||
key=EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME,
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
translation_key="program_finish_time",
|
||||
appliance_types=(
|
||||
@@ -67,13 +56,13 @@ BSH_PROGRAM_SENSORS = (
|
||||
),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="BSH.Common.Option.Duration",
|
||||
key=EventKey.BSH_COMMON_OPTION_DURATION,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
appliance_types=("Oven",),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="BSH.Common.Option.ProgramProgress",
|
||||
key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
translation_key="program_progress",
|
||||
appliance_types=APPLIANCES_WITH_PROGRAMS,
|
||||
@@ -82,7 +71,7 @@ BSH_PROGRAM_SENSORS = (
|
||||
|
||||
SENSORS = (
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=BSH_OPERATION_STATE,
|
||||
key=StatusKey.BSH_COMMON_OPERATION_STATE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"inactive",
|
||||
@@ -98,7 +87,7 @@ SENSORS = (
|
||||
translation_key="operation_state",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=BSH_DOOR_STATE,
|
||||
key=StatusKey.BSH_COMMON_DOOR_STATE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"closed",
|
||||
@@ -108,59 +97,59 @@ SENSORS = (
|
||||
translation_key="door",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffee",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="coffee_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterPowderCoffee",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="powder_coffee_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWater",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER,
|
||||
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="hot_water_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotWaterCups",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="hot_water_cups_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterHotMilk",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="hot_milk_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterFrothyMilk",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="frothy_milk_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterMilk",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="milk_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterCoffeeAndMilk",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="coffee_and_milk_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Status.BeverageCounterRistrettoEspresso",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
translation_key="ristretto_espresso_counter",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="BSH.Common.Status.BatteryLevel",
|
||||
key=StatusKey.BSH_COMMON_BATTERY_LEVEL,
|
||||
device_class=SensorDeviceClass.BATTERY,
|
||||
translation_key="battery_level",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="BSH.Common.Status.Video.CameraState",
|
||||
key=StatusKey.BSH_COMMON_VIDEO_CAMERA_STATE,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"disabled",
|
||||
@@ -174,7 +163,7 @@ SENSORS = (
|
||||
translation_key="camera_state",
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key="ConsumerProducts.CleaningRobot.Status.LastSelectedMap",
|
||||
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LAST_SELECTED_MAP,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=[
|
||||
"tempmap",
|
||||
@@ -188,7 +177,7 @@ SENSORS = (
|
||||
|
||||
EVENT_SENSORS = (
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -196,7 +185,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("FridgeFreezer", "Freezer"),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=REFRIGERATION_EVENT_DOOR_ALARM_REFRIGERATOR,
|
||||
key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_REFRIGERATOR,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -204,7 +193,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("FridgeFreezer", "Refrigerator"),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=REFRIGERATION_EVENT_TEMP_ALARM_FREEZER,
|
||||
key=EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_TEMPERATURE_ALARM_FREEZER,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -212,7 +201,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("FridgeFreezer", "Freezer"),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -220,7 +209,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("CoffeeMaker",),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=COFFEE_EVENT_WATER_TANK_EMPTY,
|
||||
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_WATER_TANK_EMPTY,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -228,7 +217,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("CoffeeMaker",),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=COFFEE_EVENT_DRIP_TRAY_FULL,
|
||||
key=EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_DRIP_TRAY_FULL,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -236,7 +225,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("CoffeeMaker",),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
|
||||
key=EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -244,7 +233,7 @@ EVENT_SENSORS = (
|
||||
appliance_types=("Dishwasher",),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
|
||||
key=EventKey.DISHCARE_DISHWASHER_EVENT_RINSE_AID_NEARLY_EMPTY,
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=EVENT_OPTIONS,
|
||||
default_value="off",
|
||||
@@ -261,33 +250,30 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect sensor."""
|
||||
|
||||
def get_entities() -> list[SensorEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[SensorEntity] = []
|
||||
for device in entry.runtime_data.devices:
|
||||
entities.extend(
|
||||
HomeConnectSensor(
|
||||
device,
|
||||
description,
|
||||
)
|
||||
for description in EVENT_SENSORS
|
||||
if description.appliance_types
|
||||
and device.appliance.type in description.appliance_types
|
||||
entities: list[SensorEntity] = []
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities.extend(
|
||||
HomeConnectEventSensor(
|
||||
entry.runtime_data,
|
||||
appliance,
|
||||
description,
|
||||
)
|
||||
entities.extend(
|
||||
HomeConnectProgramSensor(device, desc)
|
||||
for desc in BSH_PROGRAM_SENSORS
|
||||
if desc.appliance_types
|
||||
and device.appliance.type in desc.appliance_types
|
||||
)
|
||||
entities.extend(
|
||||
HomeConnectSensor(device, description)
|
||||
for description in SENSORS
|
||||
if description.key in device.appliance.status
|
||||
)
|
||||
return entities
|
||||
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(await hass.async_add_executor_job(get_entities), True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
@@ -295,44 +281,25 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
|
||||
|
||||
entity_description: HomeConnectSensorEntityDescription
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the sensor's status."""
|
||||
appliance_status = self.device.appliance.status
|
||||
if (
|
||||
self.bsh_key not in appliance_status
|
||||
or ATTR_VALUE not in appliance_status[self.bsh_key]
|
||||
):
|
||||
self._attr_native_value = self.entity_description.default_value
|
||||
_LOGGER.debug("Updated, new state: %s", self._attr_native_value)
|
||||
return
|
||||
status = appliance_status[self.bsh_key]
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the sensor."""
|
||||
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
|
||||
self._update_native_value(status)
|
||||
|
||||
def _update_native_value(self, status: str | float) -> None:
|
||||
"""Set the value of the sensor based on the given value."""
|
||||
match self.device_class:
|
||||
case SensorDeviceClass.TIMESTAMP:
|
||||
if ATTR_VALUE not in status:
|
||||
self._attr_native_value = None
|
||||
elif (
|
||||
self._attr_native_value is not None
|
||||
and isinstance(self._attr_native_value, datetime)
|
||||
and self._attr_native_value < dt_util.utcnow()
|
||||
):
|
||||
# if the date is supposed to be in the future but we're
|
||||
# already past it, set state to None.
|
||||
self._attr_native_value = None
|
||||
else:
|
||||
seconds = float(status[ATTR_VALUE])
|
||||
self._attr_native_value = dt_util.utcnow() + timedelta(
|
||||
seconds=seconds
|
||||
)
|
||||
self._attr_native_value = dt_util.utcnow() + timedelta(
|
||||
seconds=cast(float, status)
|
||||
)
|
||||
case SensorDeviceClass.ENUM:
|
||||
# Value comes back as an enum, we only really care about the
|
||||
# last part, so split it off
|
||||
# https://developer.home-connect.com/docs/status/operation_state
|
||||
self._attr_native_value = slugify(
|
||||
cast(str, status.get(ATTR_VALUE)).split(".")[-1]
|
||||
)
|
||||
self._attr_native_value = slugify(cast(str, status).split(".")[-1])
|
||||
case _:
|
||||
self._attr_native_value = status.get(ATTR_VALUE)
|
||||
_LOGGER.debug("Updated, new state: %s", self._attr_native_value)
|
||||
self._attr_native_value = status
|
||||
|
||||
|
||||
class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
@@ -340,6 +307,31 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
|
||||
program_running: bool = False
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register listener."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.coordinator.async_add_listener(
|
||||
self._handle_operation_state_event,
|
||||
(self.appliance.info.ha_id, EventKey.BSH_COMMON_STATUS_OPERATION_STATE),
|
||||
)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_operation_state_event(self) -> None:
|
||||
"""Update status when an event for the entity is received."""
|
||||
self.program_running = (
|
||||
status := self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
) is not None and status.value in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
if not self.program_running:
|
||||
# reset the value when the program is not running, paused or finished
|
||||
self._attr_native_value = None
|
||||
self.async_write_ha_state()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return true if the sensor is available."""
|
||||
@@ -347,20 +339,20 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
# Otherwise, some sensors report erroneous values.
|
||||
return super().available and self.program_running
|
||||
|
||||
async def async_update(self) -> None:
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the program sensor's status."""
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
if event:
|
||||
self._update_native_value(event.value)
|
||||
|
||||
|
||||
class HomeConnectEventSensor(HomeConnectSensor):
|
||||
"""Sensor class for Home Connect events."""
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the sensor's status."""
|
||||
self.program_running = (
|
||||
BSH_OPERATION_STATE in (appliance_status := self.device.appliance.status)
|
||||
and ATTR_VALUE in appliance_status[BSH_OPERATION_STATE]
|
||||
and appliance_status[BSH_OPERATION_STATE][ATTR_VALUE]
|
||||
in [
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
]
|
||||
)
|
||||
if self.program_running:
|
||||
await super().async_update()
|
||||
else:
|
||||
# reset the value when the program is not running, paused or finished
|
||||
self._attr_native_value = None
|
||||
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
|
||||
if event:
|
||||
self._update_native_value(event.value)
|
||||
elif not self._attr_native_value:
|
||||
self._attr_native_value = self.entity_description.default_value
|
||||
|
||||
@@ -26,64 +26,67 @@
|
||||
"message": "Appliance for device ID {device_id} not found"
|
||||
},
|
||||
"turn_on_light": {
|
||||
"message": "Error turning on {entity_id}: {description}"
|
||||
"message": "Error turning on {entity_id}: {error}"
|
||||
},
|
||||
"turn_off_light": {
|
||||
"message": "Error turning off {entity_id}: {description}"
|
||||
"message": "Error turning off {entity_id}: {error}"
|
||||
},
|
||||
"set_light_brightness": {
|
||||
"message": "Error setting brightness of {entity_id}: {description}"
|
||||
"message": "Error setting brightness of {entity_id}: {error}"
|
||||
},
|
||||
"select_light_custom_color": {
|
||||
"message": "Error selecting custom color of {entity_id}: {description}"
|
||||
"message": "Error selecting custom color of {entity_id}: {error}"
|
||||
},
|
||||
"set_light_color": {
|
||||
"message": "Error setting color of {entity_id}: {description}"
|
||||
"message": "Error setting color of {entity_id}: {error}"
|
||||
},
|
||||
"set_setting_entity": {
|
||||
"message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {description}"
|
||||
"message": "Error assigning the value \"{value}\" to the setting \"{key}\" for {entity_id}: {error}"
|
||||
},
|
||||
"set_setting": {
|
||||
"message": "Error assigning the value \"{value}\" to the setting \"{key}\": {description}"
|
||||
"message": "Error assigning the value \"{value}\" to the setting \"{key}\": {error}"
|
||||
},
|
||||
"turn_on": {
|
||||
"message": "Error turning on {entity_id} ({key}): {description}"
|
||||
"message": "Error turning on {entity_id} ({key}): {error}"
|
||||
},
|
||||
"turn_off": {
|
||||
"message": "Error turning off {entity_id} ({key}): {description}"
|
||||
"message": "Error turning off {entity_id} ({key}): {error}"
|
||||
},
|
||||
"select_program": {
|
||||
"message": "Error selecting program {program}: {description}"
|
||||
"message": "Error selecting program {program}: {error}"
|
||||
},
|
||||
"start_program": {
|
||||
"message": "Error starting program {program}: {description}"
|
||||
"message": "Error starting program {program}: {error}"
|
||||
},
|
||||
"pause_program": {
|
||||
"message": "Error pausing program: {description}"
|
||||
"message": "Error pausing program: {error}"
|
||||
},
|
||||
"stop_program": {
|
||||
"message": "Error stopping program: {description}"
|
||||
"message": "Error stopping program: {error}"
|
||||
},
|
||||
"set_options_active_program": {
|
||||
"message": "Error setting options for the active program: {description}"
|
||||
"message": "Error setting options for the active program: {error}"
|
||||
},
|
||||
"set_options_selected_program": {
|
||||
"message": "Error setting options for the selected program: {description}"
|
||||
"message": "Error setting options for the selected program: {error}"
|
||||
},
|
||||
"execute_command": {
|
||||
"message": "Error executing command {command}: {description}"
|
||||
"message": "Error executing command {command}: {error}"
|
||||
},
|
||||
"power_on": {
|
||||
"message": "Error turning on {appliance_name}: {description}"
|
||||
"message": "Error turning on {appliance_name}: {error}"
|
||||
},
|
||||
"power_off": {
|
||||
"message": "Error turning off {appliance_name} with value \"{value}\": {description}"
|
||||
"message": "Error turning off {appliance_name} with value \"{value}\": {error}"
|
||||
},
|
||||
"turn_off_not_supported": {
|
||||
"message": "{appliance_name} does not support turning off or entering standby mode."
|
||||
},
|
||||
"unable_to_retrieve_turn_off": {
|
||||
"message": "Unable to turn off {appliance_name} because its support for turning off or entering standby mode could not be determined."
|
||||
},
|
||||
"fetch_api_error": {
|
||||
"message": "Error obtaining data from the API: {error}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Provides a switch for Home Connect."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import EnumerateAvailableProgram
|
||||
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
@@ -18,87 +19,83 @@ from homeassistant.helpers.issue_registry import (
|
||||
async_create_issue,
|
||||
async_delete_issue,
|
||||
)
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
|
||||
from .const import (
|
||||
APPLIANCES_WITH_PROGRAMS,
|
||||
ATTR_ALLOWED_VALUES,
|
||||
ATTR_CONSTRAINTS,
|
||||
ATTR_VALUE,
|
||||
BSH_ACTIVE_PROGRAM,
|
||||
BSH_CHILD_LOCK_STATE,
|
||||
BSH_POWER_OFF,
|
||||
BSH_POWER_ON,
|
||||
BSH_POWER_STANDBY,
|
||||
BSH_POWER_STATE,
|
||||
DOMAIN,
|
||||
REFRIGERATION_DISPENSER,
|
||||
REFRIGERATION_SUPERMODEFREEZER,
|
||||
REFRIGERATION_SUPERMODEREFRIGERATOR,
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME,
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
)
|
||||
from .entity import HomeConnectDevice, HomeConnectEntity
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SWITCHES = (
|
||||
SwitchEntityDescription(
|
||||
key=BSH_CHILD_LOCK_STATE,
|
||||
key=SettingKey.BSH_COMMON_CHILD_LOCK,
|
||||
translation_key="child_lock",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="ConsumerProducts.CoffeeMaker.Setting.CupWarmer",
|
||||
key=SettingKey.CONSUMER_PRODUCTS_COFFEE_MAKER_CUP_WARMER,
|
||||
translation_key="cup_warmer",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=REFRIGERATION_SUPERMODEFREEZER,
|
||||
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER,
|
||||
translation_key="freezer_super_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=REFRIGERATION_SUPERMODEREFRIGERATOR,
|
||||
key=SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_REFRIGERATOR,
|
||||
translation_key="refrigerator_super_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Refrigeration.Common.Setting.EcoMode",
|
||||
key=SettingKey.REFRIGERATION_COMMON_ECO_MODE,
|
||||
translation_key="eco_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Cooking.Oven.Setting.SabbathMode",
|
||||
key=SettingKey.COOKING_OVEN_SABBATH_MODE,
|
||||
translation_key="sabbath_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Refrigeration.Common.Setting.SabbathMode",
|
||||
key=SettingKey.REFRIGERATION_COMMON_SABBATH_MODE,
|
||||
translation_key="sabbath_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Refrigeration.Common.Setting.VacationMode",
|
||||
key=SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
|
||||
translation_key="vacation_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Refrigeration.Common.Setting.FreshMode",
|
||||
key=SettingKey.REFRIGERATION_COMMON_FRESH_MODE,
|
||||
translation_key="fresh_mode",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=REFRIGERATION_DISPENSER,
|
||||
key=SettingKey.REFRIGERATION_COMMON_DISPENSER_ENABLED,
|
||||
translation_key="dispenser_enabled",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Refrigeration.Common.Setting.Door.AssistantFridge",
|
||||
key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FRIDGE,
|
||||
translation_key="door_assistant_fridge",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key="Refrigeration.Common.Setting.Door.AssistantFreezer",
|
||||
key=SettingKey.REFRIGERATION_COMMON_DOOR_ASSISTANT_FREEZER,
|
||||
translation_key="door_assistant_freezer",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
|
||||
key=BSH_POWER_STATE,
|
||||
key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
translation_key="power",
|
||||
)
|
||||
|
||||
@@ -110,29 +107,26 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect switch."""
|
||||
|
||||
def get_entities() -> list[SwitchEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[SwitchEntity] = []
|
||||
for device in entry.runtime_data.devices:
|
||||
if device.appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
programs = device.appliance.get_programs_available()
|
||||
if programs:
|
||||
entities.extend(
|
||||
HomeConnectProgramSwitch(device, program)
|
||||
for program in programs
|
||||
)
|
||||
if BSH_POWER_STATE in device.appliance.status:
|
||||
entities.append(HomeConnectPowerSwitch(device))
|
||||
entities.extend(
|
||||
HomeConnectSwitch(device, description)
|
||||
for description in SWITCHES
|
||||
if description.key in device.appliance.status
|
||||
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
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
async_add_entities(await hass.async_add_executor_job(get_entities), True)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
||||
@@ -140,11 +134,11 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on setting."""
|
||||
|
||||
_LOGGER.debug("Turning on %s", self.entity_description.key)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting, self.entity_description.key, True
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey(self.bsh_key),
|
||||
value=True,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
self._attr_available = False
|
||||
@@ -158,19 +152,15 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
self._attr_available = True
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off setting."""
|
||||
|
||||
_LOGGER.debug("Turning off %s", self.entity_description.key)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting, self.entity_description.key, False
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey(self.bsh_key),
|
||||
value=False,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error("Error while trying to turn off: %s", err)
|
||||
self._attr_available = False
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -182,38 +172,35 @@ class HomeConnectSwitch(HomeConnectEntity, SwitchEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
self._attr_available = True
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the switch's status."""
|
||||
|
||||
self._attr_is_on = self.device.appliance.status.get(
|
||||
self.entity_description.key, {}
|
||||
).get(ATTR_VALUE)
|
||||
self._attr_available = True
|
||||
_LOGGER.debug(
|
||||
"Updated %s, new state: %s",
|
||||
self.entity_description.key,
|
||||
self._attr_is_on,
|
||||
)
|
||||
self._attr_is_on = self.appliance.settings[SettingKey(self.bsh_key)].value
|
||||
|
||||
|
||||
class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
"""Switch class for Home Connect."""
|
||||
|
||||
def __init__(self, device: HomeConnectDevice, program_name: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
program: EnumerateAvailableProgram,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
desc = " ".join(["Program", program_name.split(".")[-1]])
|
||||
if device.appliance.type == "WasherDryer":
|
||||
desc = " ".join(["Program", program.key.split(".")[-1]])
|
||||
if appliance.info.type == "WasherDryer":
|
||||
desc = " ".join(
|
||||
["Program", program_name.split(".")[-3], program_name.split(".")[-1]]
|
||||
["Program", program.key.split(".")[-3], program.key.split(".")[-1]]
|
||||
)
|
||||
super().__init__(device, SwitchEntityDescription(key=program_name))
|
||||
self._attr_name = f"{device.appliance.name} {desc}"
|
||||
self._attr_unique_id = f"{device.appliance.haId}-{desc}"
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
SwitchEntityDescription(key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM),
|
||||
)
|
||||
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_name = program_name
|
||||
self.program = program
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Call when entity is added to hass."""
|
||||
@@ -266,10 +253,9 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Start the program."""
|
||||
_LOGGER.debug("Tried to turn on program %s", self.program_name)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.start_program, self.program_name
|
||||
await self.coordinator.client.start_program(
|
||||
self.appliance.info.ha_id, program_key=self.program.key
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -277,16 +263,14 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
translation_key="start_program",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
"program": self.program_name,
|
||||
"program": self.program.key,
|
||||
},
|
||||
) from err
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Stop the program."""
|
||||
_LOGGER.debug("Tried to stop program %s", self.program_name)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.device.appliance.stop_program)
|
||||
await self.coordinator.client.stop_program(self.appliance.info.ha_id)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -295,48 +279,25 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity):
|
||||
**get_dict_from_home_connect_error(err),
|
||||
},
|
||||
) from err
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the switch's status."""
|
||||
state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {})
|
||||
if state.get(ATTR_VALUE) == self.program_name:
|
||||
self._attr_is_on = True
|
||||
else:
|
||||
self._attr_is_on = False
|
||||
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
|
||||
def update_native_value(self) -> None:
|
||||
"""Update the switch's status based on if the program related to this entity is currently active."""
|
||||
event = self.appliance.events.get(EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM)
|
||||
self._attr_is_on = bool(event and event.value == self.program.key)
|
||||
|
||||
|
||||
class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
|
||||
"""Power switch class for Home Connect."""
|
||||
|
||||
power_off_state: str | None
|
||||
|
||||
def __init__(self, device: HomeConnectDevice) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
device,
|
||||
POWER_SWITCH_DESCRIPTION,
|
||||
)
|
||||
if (
|
||||
power_state := device.appliance.status.get(BSH_POWER_STATE, {}).get(
|
||||
ATTR_VALUE
|
||||
)
|
||||
) and power_state in [BSH_POWER_OFF, BSH_POWER_STANDBY]:
|
||||
self.power_off_state = power_state
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add the entity to the hass instance."""
|
||||
await super().async_added_to_hass()
|
||||
if not hasattr(self, "power_off_state"):
|
||||
await self.async_fetch_power_off_state()
|
||||
power_off_state: str | None | UndefinedType = UNDEFINED
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Switch the device on."""
|
||||
_LOGGER.debug("Tried to switch on %s", self.name)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting, BSH_POWER_STATE, BSH_POWER_ON
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
self._attr_is_on = False
|
||||
@@ -345,36 +306,36 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
|
||||
translation_key="power_on",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name,
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name,
|
||||
},
|
||||
) from err
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Switch the device off."""
|
||||
if not hasattr(self, "power_off_state"):
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_retrieve_turn_off",
|
||||
translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name
|
||||
},
|
||||
)
|
||||
if self.power_off_state is UNDEFINED:
|
||||
await self.async_fetch_power_off_state()
|
||||
if self.power_off_state is UNDEFINED:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="unable_to_retrieve_turn_off",
|
||||
translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name
|
||||
},
|
||||
)
|
||||
|
||||
if self.power_off_state is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="turn_off_not_supported",
|
||||
translation_placeholders={
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("tried to switch off %s", self.name)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting,
|
||||
BSH_POWER_STATE,
|
||||
self.power_off_state,
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=self.power_off_state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
self._attr_is_on = True
|
||||
@@ -383,46 +344,51 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
|
||||
translation_key="power_off",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.device.appliance.name,
|
||||
SVE_TRANSLATION_PLACEHOLDER_APPLIANCE_NAME: self.appliance.info.name,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: self.power_off_state,
|
||||
},
|
||||
) from err
|
||||
self.async_entity_update()
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the switch's status."""
|
||||
if (
|
||||
self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE)
|
||||
== BSH_POWER_ON
|
||||
):
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
power_state = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE]
|
||||
value = cast(str, power_state.value)
|
||||
if value == BSH_POWER_ON:
|
||||
self._attr_is_on = True
|
||||
elif (
|
||||
hasattr(self, "power_off_state")
|
||||
and self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE)
|
||||
== self.power_off_state
|
||||
isinstance(self.power_off_state, str)
|
||||
and self.power_off_state
|
||||
and value == self.power_off_state
|
||||
):
|
||||
self._attr_is_on = False
|
||||
elif self.power_off_state is UNDEFINED and value in [
|
||||
BSH_POWER_OFF,
|
||||
BSH_POWER_STANDBY,
|
||||
]:
|
||||
self.power_off_state = value
|
||||
self._attr_is_on = False
|
||||
else:
|
||||
self._attr_is_on = None
|
||||
_LOGGER.debug("Updated, new state: %s", self._attr_is_on)
|
||||
|
||||
async def async_fetch_power_off_state(self) -> None:
|
||||
"""Fetch the power off state."""
|
||||
try:
|
||||
data = await self.hass.async_add_executor_job(
|
||||
self.device.appliance.get, f"/settings/{self.bsh_key}"
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error("An error occurred: %s", err)
|
||||
return
|
||||
if not data or not (
|
||||
allowed_values := data.get(ATTR_CONSTRAINTS, {}).get(ATTR_ALLOWED_VALUES)
|
||||
):
|
||||
data = self.appliance.settings[SettingKey.BSH_COMMON_POWER_STATE]
|
||||
|
||||
if not data.constraints or not data.constraints.allowed_values:
|
||||
try:
|
||||
data = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
_LOGGER.error("An error occurred fetching the power settings: %s", err)
|
||||
return
|
||||
if not data.constraints or not data.constraints.allowed_values:
|
||||
return
|
||||
|
||||
if BSH_POWER_OFF in allowed_values:
|
||||
if BSH_POWER_OFF in data.constraints.allowed_values:
|
||||
self.power_off_state = BSH_POWER_OFF
|
||||
elif BSH_POWER_STANDBY in allowed_values:
|
||||
elif BSH_POWER_STANDBY in data.constraints.allowed_values:
|
||||
self.power_off_state = BSH_POWER_STANDBY
|
||||
else:
|
||||
self.power_off_state = None
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
"""Provides time enties for Home Connect."""
|
||||
|
||||
from datetime import time
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.time import TimeEntity, TimeEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import HomeConnectConfigEntry, get_dict_from_home_connect_error
|
||||
from .const import (
|
||||
ATTR_VALUE,
|
||||
DOMAIN,
|
||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
)
|
||||
from .coordinator import HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
TIME_ENTITIES = (
|
||||
TimeEntityDescription(
|
||||
key="BSH.Common.Setting.AlarmClock",
|
||||
key=SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
translation_key="alarm_clock",
|
||||
),
|
||||
)
|
||||
@@ -39,16 +37,14 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Set up the Home Connect switch."""
|
||||
|
||||
def get_entities() -> list[HomeConnectTimeEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectTimeEntity(device, description)
|
||||
async_add_entities(
|
||||
[
|
||||
HomeConnectTimeEntity(entry.runtime_data, appliance, description)
|
||||
for description in TIME_ENTITIES
|
||||
for device in entry.runtime_data.devices
|
||||
if description.key in device.appliance.status
|
||||
]
|
||||
|
||||
async_add_entities(await hass.async_add_executor_job(get_entities), True)
|
||||
for appliance in entry.runtime_data.data.values()
|
||||
if description.key in appliance.settings
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def seconds_to_time(seconds: int) -> time:
|
||||
@@ -68,17 +64,11 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
|
||||
|
||||
async def async_set_value(self, value: time) -> None:
|
||||
"""Set the native value of the entity."""
|
||||
_LOGGER.debug(
|
||||
"Tried to set value %s to %s for %s",
|
||||
value,
|
||||
self.bsh_key,
|
||||
self.entity_id,
|
||||
)
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self.device.appliance.set_setting,
|
||||
self.bsh_key,
|
||||
time_to_seconds(value),
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=SettingKey(self.bsh_key),
|
||||
value=time_to_seconds(value),
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
@@ -92,16 +82,7 @@ class HomeConnectTimeEntity(HomeConnectEntity, TimeEntity):
|
||||
},
|
||||
) from err
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""Update the Time setting status."""
|
||||
data = self.device.appliance.status.get(self.bsh_key)
|
||||
if data is None:
|
||||
_LOGGER.error("No value for %s", self.bsh_key)
|
||||
self._attr_native_value = None
|
||||
return
|
||||
seconds = data.get(ATTR_VALUE, None)
|
||||
if seconds is not None:
|
||||
self._attr_native_value = seconds_to_time(seconds)
|
||||
else:
|
||||
self._attr_native_value = None
|
||||
_LOGGER.debug("Updated, new value: %s", self._attr_native_value)
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
|
||||
self._attr_native_value = seconds_to_time(data.value)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Utility functions for Home Connect."""
|
||||
|
||||
import re
|
||||
|
||||
from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError
|
||||
|
||||
RE_CAMEL_CASE = re.compile(r"(?<!^)(?=[A-Z])|(?=\d)(?<=\D)")
|
||||
|
||||
|
||||
def get_dict_from_home_connect_error(
|
||||
err: HomeConnectError,
|
||||
) -> dict[str, str]:
|
||||
"""Return a translation string from a Home Connect error."""
|
||||
return {
|
||||
"error": str(err)
|
||||
if isinstance(err, HomeConnectApiError)
|
||||
else type(err).__name__
|
||||
}
|
||||
|
||||
|
||||
def bsh_key_to_translation_key(bsh_key: str) -> str:
|
||||
"""Convert a BSH key to a translation key format.
|
||||
|
||||
This function takes a BSH key, such as `Dishcare.Dishwasher.Program.Eco50`,
|
||||
and converts it to a translation key format, such as `dishcare_dishwasher_bsh_key_eco50`.
|
||||
"""
|
||||
return "_".join(
|
||||
RE_CAMEL_CASE.sub("_", split) for split in bsh_key.split(".")
|
||||
).lower()
|
||||
@@ -174,8 +174,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]",
|
||||
"unknown_error": "[%key:common::config_flow::error::unknown%]"
|
||||
"authorization_failed": "[%key:component::homewizard::config::error::authorization_failed%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "incomfort",
|
||||
"name": "Intergas InComfort/Intouch Lan2RF gateway",
|
||||
"name": "Intergas gateway",
|
||||
"codeowners": ["@jbouwh"],
|
||||
"config_flow": true,
|
||||
"dhcp": [
|
||||
|
||||
@@ -2,20 +2,20 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"description": "Set up new Intergas InComfort Lan2RF Gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.",
|
||||
"description": "Set up new Intergas gateway, some older systems might not need credentials to be set up. For newer devices authentication is required.",
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "Hostname or IP-address of the Intergas InComfort Lan2RF Gateway.",
|
||||
"host": "Hostname or IP-address of the Intergas gateway.",
|
||||
"username": "The username to log into the gateway. This is `admin` in most cases.",
|
||||
"password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices."
|
||||
"password": "The password to log into the gateway, is printed at the bottom of the gateway or is `intergas` for some older devices."
|
||||
}
|
||||
},
|
||||
"dhcp_auth": {
|
||||
"title": "Set up Intergas InComfort Lan2RF Gateway",
|
||||
"title": "Set up Intergas gateway",
|
||||
"description": "Please enter authentication details for gateway {host}",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
@@ -23,12 +23,12 @@
|
||||
},
|
||||
"data_description": {
|
||||
"username": "The username to log into the gateway. This is `admin` in most cases.",
|
||||
"password": "The password to log into the gateway, is printed at the bottom of the Lan2RF Gateway or is `intergas` for some older devices."
|
||||
"password": "The password to log into the gateway, is printed at the bottom of the Gateway or is `intergas` for some older devices."
|
||||
}
|
||||
},
|
||||
"dhcp_confirm": {
|
||||
"title": "Set up Intergas InComfort Lan2RF Gateway",
|
||||
"description": "Do you want to set up the discovered Intergas InComfort Lan2RF Gateway ({host})?"
|
||||
"title": "Set up Intergas gateway",
|
||||
"description": "Do you want to set up the discovered Intergas gateway ({host})?"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
@@ -48,9 +48,9 @@
|
||||
"error": {
|
||||
"auth_error": "Invalid credentials.",
|
||||
"no_heaters": "No heaters found.",
|
||||
"not_found": "No Lan2RF gateway found.",
|
||||
"timeout_error": "Time out when connecting to Lan2RF gateway.",
|
||||
"unknown": "Unknown error when connecting to Lan2RF gateway."
|
||||
"not_found": "No gateway found.",
|
||||
"timeout_error": "Time out when connecting to the gateway.",
|
||||
"unknown": "Unknown error when connecting to the gateway."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
@@ -70,7 +70,7 @@
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
"title": "Intergas InComfort Lan2RF Gateway options",
|
||||
"title": "Intergas gateway options",
|
||||
"data": {
|
||||
"legacy_setpoint_status": "Legacy setpoint handling"
|
||||
},
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/nest",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["google_nest_sdm"],
|
||||
"requirements": ["google-nest-sdm==7.1.0"]
|
||||
"requirements": ["google-nest-sdm==7.1.1"]
|
||||
}
|
||||
|
||||
@@ -190,7 +190,13 @@ class OneDriveBackupAgent(BackupAgent):
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
await self._get_backup_file_item(backup_id).delete()
|
||||
|
||||
try:
|
||||
await self._get_backup_file_item(backup_id).delete()
|
||||
except APIError as err:
|
||||
if err.response_status_code == 404:
|
||||
return
|
||||
raise
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
|
||||
@@ -78,7 +78,7 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
self.logger.exception("Unknown error")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
drive = response.json()
|
||||
drive: dict = response.json()
|
||||
|
||||
await self.async_set_unique_id(drive["parentReference"]["driveId"])
|
||||
|
||||
@@ -94,7 +94,10 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
title = f"{drive['shared']['owner']['user']['displayName']}'s OneDrive"
|
||||
user = drive.get("createdBy", {}).get("user", {}).get("displayName")
|
||||
|
||||
title = f"{user}'s OneDrive" if user else "OneDrive"
|
||||
|
||||
return self.async_create_entry(title=title, data=data)
|
||||
|
||||
async def async_step_reauth(
|
||||
|
||||
@@ -147,6 +147,7 @@ class OneWireFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders={"host": self._discovery_data[CONF_HOST]},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ DOMAIN = "onewire"
|
||||
DEVICE_KEYS_0_3 = range(4)
|
||||
DEVICE_KEYS_0_7 = range(8)
|
||||
DEVICE_KEYS_A_B = ("A", "B")
|
||||
DEVICE_KEYS_A_D = ("A", "B", "C", "D")
|
||||
|
||||
DEVICE_SUPPORT = {
|
||||
"05": (),
|
||||
@@ -17,6 +18,7 @@ DEVICE_SUPPORT = {
|
||||
"12": (),
|
||||
"1D": (),
|
||||
"1F": (),
|
||||
"20": (),
|
||||
"22": (),
|
||||
"26": (),
|
||||
"28": (),
|
||||
|
||||
@@ -33,6 +33,7 @@ from homeassistant.helpers.typing import StateType
|
||||
from .const import (
|
||||
DEVICE_KEYS_0_3,
|
||||
DEVICE_KEYS_A_B,
|
||||
DEVICE_KEYS_A_D,
|
||||
OPTION_ENTRY_DEVICE_OPTIONS,
|
||||
OPTION_ENTRY_SENSOR_PRECISION,
|
||||
PRECISION_MAPPING_FAMILY_28,
|
||||
@@ -108,6 +109,33 @@ DEVICE_SENSORS: dict[str, tuple[OneWireSensorEntityDescription, ...]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
),
|
||||
"20": tuple(
|
||||
[
|
||||
OneWireSensorEntityDescription(
|
||||
key=f"latestvolt.{device_key}",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
entity_registry_enabled_default=False,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
read_mode=READ_MODE_FLOAT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="latest_voltage_id",
|
||||
translation_placeholders={"id": str(device_key)},
|
||||
)
|
||||
for device_key in DEVICE_KEYS_A_D
|
||||
]
|
||||
+ [
|
||||
OneWireSensorEntityDescription(
|
||||
key=f"volt.{device_key}",
|
||||
device_class=SensorDeviceClass.VOLTAGE,
|
||||
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
|
||||
read_mode=READ_MODE_FLOAT,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
translation_key="voltage_id",
|
||||
translation_placeholders={"id": str(device_key)},
|
||||
)
|
||||
for device_key in DEVICE_KEYS_A_D
|
||||
]
|
||||
),
|
||||
"22": (SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,),
|
||||
"26": (
|
||||
SIMPLE_TEMPERATURE_SENSOR_DESCRIPTION,
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to set up OWServer from {host}?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
@@ -71,12 +74,18 @@
|
||||
"humidity_raw": {
|
||||
"name": "Raw humidity"
|
||||
},
|
||||
"latest_voltage_id": {
|
||||
"name": "Latest voltage {id}"
|
||||
},
|
||||
"moisture_id": {
|
||||
"name": "Moisture {id}"
|
||||
},
|
||||
"thermocouple_temperature_k": {
|
||||
"name": "Thermocouple K temperature"
|
||||
},
|
||||
"voltage_id": {
|
||||
"name": "Voltage {id}"
|
||||
},
|
||||
"voltage_vad": {
|
||||
"name": "VAD voltage"
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@ rules:
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: todo
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: done
|
||||
comment: |
|
||||
@@ -45,8 +45,8 @@ rules:
|
||||
# Gold
|
||||
devices: todo
|
||||
diagnostics: todo
|
||||
discovery: todo
|
||||
discovery-update-info: todo
|
||||
discovery: done
|
||||
discovery-update-info: done
|
||||
docs-data-update: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
|
||||
@@ -186,7 +186,7 @@ RPC_NUMBERS: Final = {
|
||||
mode_fn=lambda config: VIRTUAL_NUMBER_MODE_MAP.get(
|
||||
config["meta"]["ui"]["view"], NumberMode.BOX
|
||||
),
|
||||
step_fn=lambda config: config["meta"]["ui"]["step"],
|
||||
step_fn=lambda config: config["meta"]["ui"].get("step"),
|
||||
# If the unit is not set, the device sends an empty string
|
||||
unit=lambda config: config["meta"]["ui"]["unit"]
|
||||
if config["meta"]["ui"]["unit"]
|
||||
@@ -208,7 +208,7 @@ RPC_NUMBERS: Final = {
|
||||
method_params_fn=lambda idx, value: {
|
||||
"id": idx,
|
||||
"method": "Trv.SetPosition",
|
||||
"params": {"id": 0, "pos": value},
|
||||
"params": {"id": 0, "pos": int(value)},
|
||||
},
|
||||
removal_condition=lambda config, _status, key: config[key].get("enable", True)
|
||||
is True,
|
||||
|
||||
@@ -144,11 +144,15 @@ class SmFirmwareUpdateCoordinator(SmBaseDataUpdateCoordinator[SmFwData]):
|
||||
async def _internal_update_data(self) -> SmFwData:
|
||||
"""Fetch data from the SMLIGHT device."""
|
||||
info = await self.client.get_info()
|
||||
esp_firmware = None
|
||||
zb_firmware = None
|
||||
|
||||
return SmFwData(
|
||||
info=info,
|
||||
esp_firmware=await self.client.get_firmware_version(info.fw_channel),
|
||||
zb_firmware=await self.client.get_firmware_version(
|
||||
try:
|
||||
esp_firmware = await self.client.get_firmware_version(info.fw_channel)
|
||||
zb_firmware = await self.client.get_firmware_version(
|
||||
info.fw_channel, device=info.model, mode="zigbee"
|
||||
),
|
||||
)
|
||||
)
|
||||
except SmlightConnectionError as err:
|
||||
self.async_set_update_error(err)
|
||||
|
||||
return SmFwData(info=info, esp_firmware=esp_firmware, zb_firmware=zb_firmware)
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smlight",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["pysmlight==0.1.6"],
|
||||
"requirements": ["pysmlight==0.2.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_slzb-06._tcp.local."
|
||||
|
||||
@@ -34,7 +34,11 @@ from homeassistant.helpers import (
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_call_later, async_track_time_interval
|
||||
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_MODEL_NAME,
|
||||
ATTR_UPNP_UDN,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.async_ import create_eager_task
|
||||
|
||||
@@ -503,7 +507,7 @@ class SonosDiscoveryManager:
|
||||
def _async_ssdp_discovered_player(
|
||||
self, info: SsdpServiceInfo, change: ssdp.SsdpChange
|
||||
) -> None:
|
||||
uid = info.upnp[ssdp.ATTR_UPNP_UDN]
|
||||
uid = info.upnp[ATTR_UPNP_UDN]
|
||||
if not uid.startswith("uuid:RINCON_"):
|
||||
return
|
||||
uid = uid[5:]
|
||||
@@ -522,7 +526,7 @@ class SonosDiscoveryManager:
|
||||
cast(str, urlparse(info.ssdp_location).hostname),
|
||||
uid,
|
||||
info.ssdp_headers.get("X-RINCON-BOOTSEQ"),
|
||||
cast(str, info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME)),
|
||||
cast(str, info.upnp.get(ATTR_UPNP_MODEL_NAME)),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
@@ -70,7 +70,13 @@
|
||||
"data": {
|
||||
"scan_interval": "Minutes between scans",
|
||||
"timeout": "Timeout (seconds)",
|
||||
"snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)"
|
||||
"snap_profile_type": "Quality level of camera snapshots (0:high 1:medium 2:low)",
|
||||
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data::backup_share%]",
|
||||
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data::backup_path%]"
|
||||
},
|
||||
"data_description": {
|
||||
"backup_share": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_share%]",
|
||||
"backup_path": "[%key:component::synology_dsm::config::step::backup_share::data_description::backup_path%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Any, Final
|
||||
import wave
|
||||
|
||||
from voip_utils import SIP_PORT, RtpDatagramProtocol
|
||||
from voip_utils.sip import SipEndpoint, get_sip_endpoint
|
||||
from voip_utils.sip import SipDatagramProtocol, SipEndpoint, get_sip_endpoint
|
||||
|
||||
from homeassistant.components import tts
|
||||
from homeassistant.components.assist_pipeline import PipelineEvent, PipelineEventType
|
||||
@@ -43,6 +43,7 @@ _PIPELINE_TIMEOUT_SEC: Final = 30
|
||||
_ANNOUNCEMENT_BEFORE_DELAY: Final = 0.5
|
||||
_ANNOUNCEMENT_AFTER_DELAY: Final = 1.0
|
||||
_ANNOUNCEMENT_HANGUP_SEC: Final = 0.5
|
||||
_ANNOUNCEMENT_RING_TIMEOUT: Final = 30
|
||||
|
||||
|
||||
class Tones(IntFlag):
|
||||
@@ -89,7 +90,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
entity_description = AssistSatelliteEntityDescription(key="assist_satellite")
|
||||
_attr_translation_key = "assist_satellite"
|
||||
_attr_name = None
|
||||
_attr_supported_features = AssistSatelliteEntityFeature.ANNOUNCE
|
||||
_attr_supported_features = (
|
||||
AssistSatelliteEntityFeature.ANNOUNCE
|
||||
| AssistSatelliteEntityFeature.START_CONVERSATION
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -116,10 +120,12 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
self._processing_tone_done = asyncio.Event()
|
||||
|
||||
self._announcement: AssistSatelliteAnnouncement | None = None
|
||||
self._announcement_done = asyncio.Event()
|
||||
self._announcement_future: asyncio.Future[Any] = asyncio.Future()
|
||||
self._announcment_start_time: float = 0.0
|
||||
self._check_announcement_ended_task: asyncio.Task | None = None
|
||||
self._last_chunk_time: float | None = None
|
||||
self._rtp_port: int | None = None
|
||||
self._run_pipeline_after_announce: bool = False
|
||||
|
||||
@property
|
||||
def pipeline_entity_id(self) -> str | None:
|
||||
@@ -170,7 +176,17 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
|
||||
Plays announcement in a loop, blocking until the caller hangs up.
|
||||
"""
|
||||
self._announcement_done.clear()
|
||||
await self._do_announce(announcement, run_pipeline_after=False)
|
||||
|
||||
async def _do_announce(
|
||||
self, announcement: AssistSatelliteAnnouncement, run_pipeline_after: bool
|
||||
) -> None:
|
||||
"""Announce media on the satellite.
|
||||
|
||||
Optionally run a voice pipeline after the announcement has finished.
|
||||
"""
|
||||
self._announcement_future = asyncio.Future()
|
||||
self._run_pipeline_after_announce = run_pipeline_after
|
||||
|
||||
if self._rtp_port is None:
|
||||
# Choose random port for RTP
|
||||
@@ -194,16 +210,34 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
host=self.voip_device.voip_id, port=SIP_PORT
|
||||
)
|
||||
|
||||
# Reset state so we can time out if needed
|
||||
self._last_chunk_time = None
|
||||
self._announcment_start_time = time.monotonic()
|
||||
self._announcement = announcement
|
||||
|
||||
# Make the call
|
||||
self.hass.data[DOMAIN].protocol.outgoing_call(
|
||||
sip_protocol: SipDatagramProtocol = self.hass.data[DOMAIN].protocol
|
||||
call_info = sip_protocol.outgoing_call(
|
||||
source=source_endpoint,
|
||||
destination=destination_endpoint,
|
||||
rtp_port=self._rtp_port,
|
||||
)
|
||||
|
||||
await self._announcement_done.wait()
|
||||
# Check if caller hung up or didn't pick up
|
||||
self._check_announcement_ended_task = (
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._check_announcement_ended(),
|
||||
"voip_announcement_ended",
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await self._announcement_future
|
||||
except TimeoutError:
|
||||
# Stop ringing
|
||||
sip_protocol.cancel_call(call_info)
|
||||
raise
|
||||
|
||||
async def _check_announcement_ended(self) -> None:
|
||||
"""Continuously checks if an audio chunk was received within a time limit.
|
||||
@@ -211,18 +245,38 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
If not, the caller is presumed to have hung up and the announcement is ended.
|
||||
"""
|
||||
while self._announcement is not None:
|
||||
current_time = time.monotonic()
|
||||
if (self._last_chunk_time is None) and (
|
||||
(current_time - self._announcment_start_time)
|
||||
> _ANNOUNCEMENT_RING_TIMEOUT
|
||||
):
|
||||
# Ring timeout
|
||||
self._announcement = None
|
||||
self._check_announcement_ended_task = None
|
||||
self._announcement_future.set_exception(
|
||||
TimeoutError("User did not pick up in time")
|
||||
)
|
||||
_LOGGER.debug("Timed out waiting for the user to pick up the phone")
|
||||
break
|
||||
|
||||
if (self._last_chunk_time is not None) and (
|
||||
(time.monotonic() - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
|
||||
(current_time - self._last_chunk_time) > _ANNOUNCEMENT_HANGUP_SEC
|
||||
):
|
||||
# Caller hung up
|
||||
self._announcement = None
|
||||
self._announcement_done.set()
|
||||
self._announcement_future.set_result(None)
|
||||
self._check_announcement_ended_task = None
|
||||
_LOGGER.debug("Announcement ended")
|
||||
break
|
||||
|
||||
await asyncio.sleep(_ANNOUNCEMENT_HANGUP_SEC / 2)
|
||||
|
||||
async def async_start_conversation(
|
||||
self, start_announcement: AssistSatelliteAnnouncement
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite."""
|
||||
await self._do_announce(start_announcement, run_pipeline_after=True)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# VoIP
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -248,16 +302,6 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
self._audio_queue.put_nowait(audio_bytes)
|
||||
elif self._run_pipeline_task is None:
|
||||
# Announcement only
|
||||
if self._check_announcement_ended_task is None:
|
||||
# Check if caller hung up
|
||||
self._check_announcement_ended_task = (
|
||||
self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
self._check_announcement_ended(),
|
||||
"voip_announcement_ended",
|
||||
)
|
||||
)
|
||||
|
||||
# Play announcement (will repeat)
|
||||
self._run_pipeline_task = self.config_entry.async_create_background_task(
|
||||
self.hass,
|
||||
@@ -317,7 +361,10 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
try:
|
||||
await asyncio.sleep(_ANNOUNCEMENT_BEFORE_DELAY)
|
||||
await self._send_tts(announcement.original_media_id, wait_for_tone=False)
|
||||
await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY)
|
||||
|
||||
if not self._run_pipeline_after_announce:
|
||||
# Delay before looping announcement
|
||||
await asyncio.sleep(_ANNOUNCEMENT_AFTER_DELAY)
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error while playing announcement")
|
||||
raise
|
||||
@@ -325,6 +372,11 @@ class VoipAssistSatellite(VoIPEntity, AssistSatelliteEntity, RtpDatagramProtocol
|
||||
self._run_pipeline_task = None
|
||||
_LOGGER.debug("Announcement finished")
|
||||
|
||||
if self._run_pipeline_after_announce:
|
||||
# Clear announcement to allow pipeline to run
|
||||
self._announcement = None
|
||||
self._announcement_future.set_result(None)
|
||||
|
||||
def _clear_audio_queue(self) -> None:
|
||||
"""Ensure audio queue is empty."""
|
||||
while not self._audio_queue.empty():
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/voip",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["voip-utils==0.3.0"]
|
||||
"requirements": ["voip-utils==0.3.1"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import logging
|
||||
|
||||
from aiohttp import ClientError
|
||||
from whirlpool.appliancesmanager import AppliancesManager
|
||||
from whirlpool.auth import Auth
|
||||
from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth
|
||||
from whirlpool.backendselector import BackendSelector
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -39,6 +39,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: WhirlpoolConfigEntry) ->
|
||||
await auth.do_auth(store=False)
|
||||
except (ClientError, TimeoutError) as ex:
|
||||
raise ConfigEntryNotReady("Cannot connect") from ex
|
||||
except WhirlpoolAccountLocked as ex:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="account_locked"
|
||||
) from ex
|
||||
|
||||
if not auth.is_access_token_valid():
|
||||
_LOGGER.error("Authentication failed")
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import Any
|
||||
from aiohttp import ClientError
|
||||
import voluptuous as vol
|
||||
from whirlpool.appliancesmanager import AppliancesManager
|
||||
from whirlpool.auth import Auth
|
||||
from whirlpool.auth import AccountLockedError as WhirlpoolAccountLocked, Auth
|
||||
from whirlpool.backendselector import BackendSelector
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
@@ -55,6 +55,8 @@ async def authenticate(
|
||||
|
||||
try:
|
||||
await auth.do_auth()
|
||||
except WhirlpoolAccountLocked:
|
||||
return "account_locked"
|
||||
except (TimeoutError, ClientError):
|
||||
return "cannot_connect"
|
||||
except Exception:
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"account_locked_error": "The account is locked. Please follow the instructions in the manufacturer's app to unlock it"
|
||||
},
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -31,6 +34,7 @@
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"account_locked": "[%key:component::whirlpool::common::account_locked_error%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
@@ -85,5 +89,10 @@
|
||||
"name": "End time"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"account_locked": {
|
||||
"message": "[%key:component::whirlpool::common::account_locked_error%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import logging
|
||||
|
||||
from xbox.webapi.api.client import XboxLiveClient
|
||||
from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList
|
||||
from xbox.webapi.common.signed_session import SignedSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
@@ -36,7 +37,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
)
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
auth = api.AsyncConfigEntryAuth(session)
|
||||
signed_session = await hass.async_add_executor_job(SignedSession)
|
||||
auth = api.AsyncConfigEntryAuth(signed_session, session)
|
||||
|
||||
client = XboxLiveClient(auth)
|
||||
consoles: SmartglassConsoleList = await client.smartglass.get_console_list()
|
||||
|
||||
@@ -11,10 +11,12 @@ from homeassistant.util.dt import utc_from_timestamp
|
||||
class AsyncConfigEntryAuth(AuthenticationManager):
|
||||
"""Provide xbox authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(self, oauth_session: OAuth2Session) -> None:
|
||||
def __init__(
|
||||
self, signed_session: SignedSession, oauth_session: OAuth2Session
|
||||
) -> None:
|
||||
"""Initialize xbox auth."""
|
||||
# Leaving out client credentials as they are handled by Home Assistant
|
||||
super().__init__(SignedSession(), "", "", "")
|
||||
super().__init__(signed_session, "", "", "")
|
||||
self._oauth_session = oauth_session
|
||||
self.oauth = self._get_oauth_token()
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"zha",
|
||||
"universal_silabs_flasher"
|
||||
],
|
||||
"requirements": ["zha==0.0.46"],
|
||||
"requirements": ["zha==0.0.47"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "10C4",
|
||||
|
||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2025
|
||||
MINOR_VERSION: Final = 2
|
||||
MINOR_VERSION: Final = 3
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
|
||||
@@ -2866,7 +2866,7 @@
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"incomfort": {
|
||||
"name": "Intergas InComfort/Intouch Lan2RF gateway",
|
||||
"name": "Intergas gateway",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
|
||||
@@ -88,6 +88,7 @@ def _base_components() -> dict[str, ModuleType]:
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components import (
|
||||
alarm_control_panel,
|
||||
assist_satellite,
|
||||
calendar,
|
||||
camera,
|
||||
climate,
|
||||
@@ -108,6 +109,7 @@ def _base_components() -> dict[str, ModuleType]:
|
||||
|
||||
return {
|
||||
"alarm_control_panel": alarm_control_panel,
|
||||
"assist_satellite": assist_satellite,
|
||||
"calendar": calendar,
|
||||
"camera": camera,
|
||||
"climate": climate,
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2025.2.0.dev0"
|
||||
version = "2025.3.0.dev0"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
||||
Generated
+7
-7
@@ -263,6 +263,9 @@ aioharmony==0.4.1
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.2.2b6
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.12.1
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.7
|
||||
|
||||
@@ -1033,7 +1036,7 @@ google-cloud-texttospeech==2.17.2
|
||||
google-generativeai==0.8.2
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==7.1.0
|
||||
google-nest-sdm==7.1.1
|
||||
|
||||
# homeassistant.components.google_photos
|
||||
google-photos-library-api==0.12.1
|
||||
@@ -1148,9 +1151,6 @@ home-assistant-frontend==20250129.0
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.1.28
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.8.0
|
||||
|
||||
# homeassistant.components.homematicip_cloud
|
||||
homematicip==1.1.7
|
||||
|
||||
@@ -2310,7 +2310,7 @@ pysmarty2==0.10.1
|
||||
pysml==0.0.12
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.1.6
|
||||
pysmlight==0.2.0
|
||||
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==6.2.6
|
||||
@@ -2991,7 +2991,7 @@ venstarcolortouch==0.19
|
||||
vilfo-api-client==0.5.0
|
||||
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.3.0
|
||||
voip-utils==0.3.1
|
||||
|
||||
# homeassistant.components.volkszaehler
|
||||
volkszaehler==0.4.0
|
||||
@@ -3131,7 +3131,7 @@ zeroconf==0.141.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.46
|
||||
zha==0.0.47
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
Generated
+7
-7
@@ -248,6 +248,9 @@ aioharmony==0.4.1
|
||||
# homeassistant.components.hassio
|
||||
aiohasupervisor==0.2.2b6
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
aiohomeconnect==0.12.1
|
||||
|
||||
# homeassistant.components.homekit_controller
|
||||
aiohomekit==3.2.7
|
||||
|
||||
@@ -883,7 +886,7 @@ google-cloud-texttospeech==2.17.2
|
||||
google-generativeai==0.8.2
|
||||
|
||||
# homeassistant.components.nest
|
||||
google-nest-sdm==7.1.0
|
||||
google-nest-sdm==7.1.1
|
||||
|
||||
# homeassistant.components.google_photos
|
||||
google-photos-library-api==0.12.1
|
||||
@@ -977,9 +980,6 @@ home-assistant-frontend==20250129.0
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.1.28
|
||||
|
||||
# homeassistant.components.home_connect
|
||||
homeconnect==0.8.0
|
||||
|
||||
# homeassistant.components.homematicip_cloud
|
||||
homematicip==1.1.7
|
||||
|
||||
@@ -1882,7 +1882,7 @@ pysmarty2==0.10.1
|
||||
pysml==0.0.12
|
||||
|
||||
# homeassistant.components.smlight
|
||||
pysmlight==0.1.6
|
||||
pysmlight==0.2.0
|
||||
|
||||
# homeassistant.components.snmp
|
||||
pysnmp==6.2.6
|
||||
@@ -2407,7 +2407,7 @@ venstarcolortouch==0.19
|
||||
vilfo-api-client==0.5.0
|
||||
|
||||
# homeassistant.components.voip
|
||||
voip-utils==0.3.0
|
||||
voip-utils==0.3.1
|
||||
|
||||
# homeassistant.components.volvooncall
|
||||
volvooncall==0.10.3
|
||||
@@ -2520,7 +2520,7 @@ zeroconf==0.141.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.46
|
||||
zha==0.0.47
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.60.0
|
||||
|
||||
@@ -40,6 +40,8 @@ def mock_tts(mock_tts_cache_dir: pathlib.Path) -> None:
|
||||
class MockAssistSatellite(AssistSatelliteEntity):
|
||||
"""Mock Assist Satellite Entity."""
|
||||
|
||||
_attr_tts_options = {"test-option": "test-value"}
|
||||
|
||||
def __init__(self, name: str, features: AssistSatelliteEntityFeature) -> None:
|
||||
"""Initialize the mock entity."""
|
||||
self._attr_unique_id = ulid_hex()
|
||||
@@ -67,6 +69,7 @@ class MockAssistSatellite(AssistSatelliteEntity):
|
||||
active_wake_words=["1234"],
|
||||
max_active_wake_words=1,
|
||||
)
|
||||
self.start_conversations = []
|
||||
|
||||
def on_pipeline_event(self, event: PipelineEvent) -> None:
|
||||
"""Handle pipeline events."""
|
||||
@@ -87,11 +90,21 @@ class MockAssistSatellite(AssistSatelliteEntity):
|
||||
"""Set the current satellite configuration."""
|
||||
self.config = config
|
||||
|
||||
async def async_start_conversation(
|
||||
self, start_announcement: AssistSatelliteConfiguration
|
||||
) -> None:
|
||||
"""Start a conversation from the satellite."""
|
||||
self.start_conversations.append((self._extra_system_prompt, start_announcement))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def entity() -> MockAssistSatellite:
|
||||
"""Mock Assist Satellite Entity."""
|
||||
return MockAssistSatellite("Test Entity", AssistSatelliteEntityFeature.ANNOUNCE)
|
||||
return MockAssistSatellite(
|
||||
"Test Entity",
|
||||
AssistSatelliteEntityFeature.ANNOUNCE
|
||||
| AssistSatelliteEntityFeature.START_CONVERSATION,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -25,11 +25,24 @@ from homeassistant.components.assist_satellite.entity import AssistSatelliteStat
|
||||
from homeassistant.components.media_source import PlayMedia
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Context, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from . import ENTITY_ID
|
||||
from .conftest import MockAssistSatellite
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def set_pipeline_tts(hass: HomeAssistant, init_components: ConfigEntry) -> None:
|
||||
"""Set up a pipeline with a TTS engine."""
|
||||
await async_update_pipeline(
|
||||
hass,
|
||||
async_get_pipeline(hass),
|
||||
tts_engine="tts.mock_entity",
|
||||
tts_language="en",
|
||||
tts_voice="test-voice",
|
||||
)
|
||||
|
||||
|
||||
async def test_entity_state(
|
||||
hass: HomeAssistant, init_components: ConfigEntry, entity: MockAssistSatellite
|
||||
) -> None:
|
||||
@@ -64,7 +77,7 @@ async def test_entity_state(
|
||||
assert kwargs["stt_stream"] is audio_stream
|
||||
assert kwargs["pipeline_id"] is None
|
||||
assert kwargs["device_id"] is entity.device_entry.id
|
||||
assert kwargs["tts_audio_output"] is None
|
||||
assert kwargs["tts_audio_output"] == {"test-option": "test-value"}
|
||||
assert kwargs["wake_word_phrase"] is None
|
||||
assert kwargs["audio_settings"] == AudioSettings(
|
||||
silence_seconds=vad.VadSensitivity.to_seconds(vad.VadSensitivity.DEFAULT)
|
||||
@@ -200,24 +213,12 @@ async def test_announce(
|
||||
expected_params: tuple[str, str],
|
||||
) -> None:
|
||||
"""Test announcing on a device."""
|
||||
await async_update_pipeline(
|
||||
hass,
|
||||
async_get_pipeline(hass),
|
||||
tts_engine="tts.mock_entity",
|
||||
tts_language="en",
|
||||
tts_voice="test-voice",
|
||||
)
|
||||
|
||||
entity._attr_tts_options = {"test-option": "test-value"}
|
||||
|
||||
original_announce = entity.async_announce
|
||||
announce_started = asyncio.Event()
|
||||
|
||||
async def async_announce(announcement):
|
||||
# Verify state change
|
||||
assert entity.state == AssistSatelliteState.RESPONDING
|
||||
await original_announce(announcement)
|
||||
announce_started.set()
|
||||
|
||||
def tts_generate_media_source_id(
|
||||
hass: HomeAssistant,
|
||||
@@ -475,3 +476,104 @@ async def test_vad_sensitivity_entity_not_found(
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await entity.async_accept_pipeline_from_satellite(audio_stream)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("service_data", "expected_params"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"start_message": "Hello",
|
||||
"extra_system_prompt": "Better system prompt",
|
||||
},
|
||||
(
|
||||
"Better system prompt",
|
||||
AssistSatelliteAnnouncement(
|
||||
message="Hello",
|
||||
media_id="https://www.home-assistant.io/resolved.mp3",
|
||||
original_media_id="media-source://generated",
|
||||
media_id_source="tts",
|
||||
),
|
||||
),
|
||||
),
|
||||
(
|
||||
{
|
||||
"start_message": "Hello",
|
||||
"start_media_id": "media-source://given",
|
||||
},
|
||||
(
|
||||
"Hello",
|
||||
AssistSatelliteAnnouncement(
|
||||
message="Hello",
|
||||
media_id="https://www.home-assistant.io/resolved.mp3",
|
||||
original_media_id="media-source://given",
|
||||
media_id_source="media_id",
|
||||
),
|
||||
),
|
||||
),
|
||||
(
|
||||
{"start_media_id": "http://example.com/given.mp3"},
|
||||
(
|
||||
None,
|
||||
AssistSatelliteAnnouncement(
|
||||
message="",
|
||||
media_id="http://example.com/given.mp3",
|
||||
original_media_id="http://example.com/given.mp3",
|
||||
media_id_source="url",
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_start_conversation(
|
||||
hass: HomeAssistant,
|
||||
init_components: ConfigEntry,
|
||||
entity: MockAssistSatellite,
|
||||
service_data: dict,
|
||||
expected_params: tuple[str, str],
|
||||
) -> None:
|
||||
"""Test starting a conversation on a device."""
|
||||
await async_update_pipeline(
|
||||
hass,
|
||||
async_get_pipeline(hass),
|
||||
conversation_engine="conversation.some_llm",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.assist_satellite.entity.tts_generate_media_source_id",
|
||||
return_value="media-source://generated",
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.media_source.async_resolve_media",
|
||||
return_value=PlayMedia(
|
||||
url="https://www.home-assistant.io/resolved.mp3",
|
||||
mime_type="audio/mp3",
|
||||
),
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
"assist_satellite",
|
||||
"start_conversation",
|
||||
service_data,
|
||||
target={"entity_id": "assist_satellite.test_entity"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity.start_conversations[0] == expected_params
|
||||
|
||||
|
||||
async def test_start_conversation_reject_builtin_agent(
|
||||
hass: HomeAssistant,
|
||||
init_components: ConfigEntry,
|
||||
entity: MockAssistSatellite,
|
||||
) -> None:
|
||||
"""Test starting a conversation on a device."""
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
"assist_satellite",
|
||||
"start_conversation",
|
||||
{"start_message": "Hey!"},
|
||||
target={"entity_id": "assist_satellite.test_entity"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine, Iterable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
@@ -52,10 +52,17 @@ TEST_BACKUP_DEF456 = AgentBackup(
|
||||
protected=False,
|
||||
size=1,
|
||||
)
|
||||
TEST_BACKUP_PATH_DEF456 = Path("custom_def456.tar")
|
||||
|
||||
TEST_DOMAIN = "test"
|
||||
|
||||
|
||||
async def aiter_from_iter(iterable: Iterable) -> AsyncIterator:
|
||||
"""Convert an iterable to an async iterator."""
|
||||
for i in iterable:
|
||||
yield i
|
||||
|
||||
|
||||
class BackupAgentTest(BackupAgent):
|
||||
"""Test backup agent."""
|
||||
|
||||
@@ -162,7 +169,13 @@ async def setup_backup_integration(
|
||||
if with_hassio and agent_id == LOCAL_AGENT_ID:
|
||||
continue
|
||||
agent = hass.data[DATA_MANAGER].backup_agents[agent_id]
|
||||
agent._backups = {backups.backup_id: backups for backups in agent_backups}
|
||||
|
||||
async def open_stream() -> AsyncIterator[bytes]:
|
||||
"""Open a stream."""
|
||||
return aiter_from_iter((b"backup data",))
|
||||
|
||||
for backup in agent_backups:
|
||||
await agent.async_upload_backup(open_stream=open_stream, backup=backup)
|
||||
if agent_id == LOCAL_AGENT_ID:
|
||||
agent._loaded_backups = True
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.backup import DOMAIN
|
||||
from homeassistant.components.backup.manager import NewBackup, WrittenBackup
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .common import TEST_BACKUP_PATH_ABC123
|
||||
from .common import TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456
|
||||
|
||||
from tests.common import get_fixture_path
|
||||
|
||||
@@ -38,10 +38,14 @@ def mocked_tarfile_fixture() -> Generator[Mock]:
|
||||
|
||||
|
||||
@pytest.fixture(name="path_glob")
|
||||
def path_glob_fixture() -> Generator[MagicMock]:
|
||||
def path_glob_fixture(hass: HomeAssistant) -> Generator[MagicMock]:
|
||||
"""Mock path glob."""
|
||||
with patch(
|
||||
"pathlib.Path.glob", return_value=[TEST_BACKUP_PATH_ABC123]
|
||||
"pathlib.Path.glob",
|
||||
return_value=[
|
||||
Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_ABC123,
|
||||
Path(hass.config.path()) / "backups" / TEST_BACKUP_PATH_DEF456,
|
||||
],
|
||||
) as path_glob:
|
||||
yield path_glob
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_delete_backup[found_backups0-True-1]
|
||||
# name: test_delete_backup[found_backups0-abc123-1-unlink_path0]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': dict({
|
||||
@@ -10,7 +10,7 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_delete_backup[found_backups1-False-0]
|
||||
# name: test_delete_backup[found_backups1-def456-1-unlink_path1]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': dict({
|
||||
@@ -21,7 +21,7 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_delete_backup[found_backups2-True-0]
|
||||
# name: test_delete_backup[found_backups2-abc123-0-None]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': dict({
|
||||
@@ -32,7 +32,7 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_load_backups[None]
|
||||
# name: test_load_backups[mock_read_backup]
|
||||
dict({
|
||||
'id': 1,
|
||||
'result': dict({
|
||||
@@ -47,7 +47,7 @@
|
||||
'type': 'result',
|
||||
})
|
||||
# ---
|
||||
# name: test_load_backups[None].1
|
||||
# name: test_load_backups[mock_read_backup].1
|
||||
dict({
|
||||
'id': 2,
|
||||
'result': dict({
|
||||
@@ -82,6 +82,29 @@
|
||||
'name': 'Test',
|
||||
'with_automatic_settings': True,
|
||||
}),
|
||||
dict({
|
||||
'addons': list([
|
||||
]),
|
||||
'agents': dict({
|
||||
'backup.local': dict({
|
||||
'protected': False,
|
||||
'size': 1,
|
||||
}),
|
||||
}),
|
||||
'backup_id': 'def456',
|
||||
'database_included': False,
|
||||
'date': '1980-01-01T00:00:00.000Z',
|
||||
'failed_agent_ids': list([
|
||||
]),
|
||||
'folders': list([
|
||||
'media',
|
||||
'share',
|
||||
]),
|
||||
'homeassistant_included': True,
|
||||
'homeassistant_version': '2024.12.0',
|
||||
'name': 'Test 2',
|
||||
'with_automatic_settings': None,
|
||||
}),
|
||||
]),
|
||||
'last_attempted_automatic_backup': None,
|
||||
'last_completed_automatic_backup': None,
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -84,7 +84,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -131,7 +131,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -179,7 +179,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -664,7 +664,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -778,7 +778,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -892,7 +892,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1016,7 +1016,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1183,7 +1183,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1297,7 +1297,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1413,7 +1413,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1527,7 +1527,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1645,7 +1645,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1767,7 +1767,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1881,7 +1881,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -1995,7 +1995,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -2109,7 +2109,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
@@ -2223,7 +2223,7 @@
|
||||
}),
|
||||
}),
|
||||
'key': 'backup',
|
||||
'minor_version': 2,
|
||||
'minor_version': 3,
|
||||
'version': 1,
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -12,21 +12,35 @@ from unittest.mock import MagicMock, mock_open, patch
|
||||
import pytest
|
||||
from syrupy import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.backup import DOMAIN
|
||||
from homeassistant.components.backup import DOMAIN, AgentBackup
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .common import TEST_BACKUP_ABC123, TEST_BACKUP_PATH_ABC123
|
||||
from .common import (
|
||||
TEST_BACKUP_ABC123,
|
||||
TEST_BACKUP_DEF456,
|
||||
TEST_BACKUP_PATH_ABC123,
|
||||
TEST_BACKUP_PATH_DEF456,
|
||||
)
|
||||
|
||||
from tests.typing import ClientSessionGenerator, WebSocketGenerator
|
||||
|
||||
|
||||
def mock_read_backup(backup_path: Path) -> AgentBackup:
|
||||
"""Mock read backup."""
|
||||
mock_backups = {
|
||||
"abc123": TEST_BACKUP_ABC123,
|
||||
"custom_def456": TEST_BACKUP_DEF456,
|
||||
}
|
||||
return mock_backups[backup_path.stem]
|
||||
|
||||
|
||||
@pytest.fixture(name="read_backup")
|
||||
def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]:
|
||||
"""Mock read backup."""
|
||||
with patch(
|
||||
"homeassistant.components.backup.backup.read_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
side_effect=mock_read_backup,
|
||||
) as read_backup:
|
||||
yield read_backup
|
||||
|
||||
@@ -34,7 +48,7 @@ def read_backup_fixture(path_glob: MagicMock) -> Generator[MagicMock]:
|
||||
@pytest.mark.parametrize(
|
||||
"side_effect",
|
||||
[
|
||||
None,
|
||||
mock_read_backup,
|
||||
OSError("Boom"),
|
||||
TarError("Boom"),
|
||||
json.JSONDecodeError("Boom", "test", 1),
|
||||
@@ -94,11 +108,21 @@ async def test_upload(
|
||||
|
||||
@pytest.mark.usefixtures("read_backup")
|
||||
@pytest.mark.parametrize(
|
||||
("found_backups", "backup_exists", "unlink_calls"),
|
||||
("found_backups", "backup_id", "unlink_calls", "unlink_path"),
|
||||
[
|
||||
([TEST_BACKUP_PATH_ABC123], True, 1),
|
||||
([TEST_BACKUP_PATH_ABC123], False, 0),
|
||||
(([], True, 0)),
|
||||
(
|
||||
[TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456],
|
||||
TEST_BACKUP_ABC123.backup_id,
|
||||
1,
|
||||
TEST_BACKUP_PATH_ABC123,
|
||||
),
|
||||
(
|
||||
[TEST_BACKUP_PATH_ABC123, TEST_BACKUP_PATH_DEF456],
|
||||
TEST_BACKUP_DEF456.backup_id,
|
||||
1,
|
||||
TEST_BACKUP_PATH_DEF456,
|
||||
),
|
||||
(([], TEST_BACKUP_ABC123.backup_id, 0, None)),
|
||||
],
|
||||
)
|
||||
async def test_delete_backup(
|
||||
@@ -108,8 +132,9 @@ async def test_delete_backup(
|
||||
snapshot: SnapshotAssertion,
|
||||
path_glob: MagicMock,
|
||||
found_backups: list[Path],
|
||||
backup_exists: bool,
|
||||
backup_id: str,
|
||||
unlink_calls: int,
|
||||
unlink_path: Path | None,
|
||||
) -> None:
|
||||
"""Test delete backup."""
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
@@ -118,12 +143,13 @@ async def test_delete_backup(
|
||||
path_glob.return_value = found_backups
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.exists", return_value=backup_exists),
|
||||
patch("pathlib.Path.unlink") as unlink,
|
||||
patch("pathlib.Path.unlink", autospec=True) as unlink,
|
||||
):
|
||||
await client.send_json_auto_id(
|
||||
{"type": "backup/delete", "backup_id": TEST_BACKUP_ABC123.backup_id}
|
||||
{"type": "backup/delete", "backup_id": backup_id}
|
||||
)
|
||||
assert await client.receive_json() == snapshot
|
||||
|
||||
assert unlink.call_count == unlink_calls
|
||||
for call in unlink.mock_calls:
|
||||
assert call.args[0] == unlink_path
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for the Backup integration."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Iterable
|
||||
from collections.abc import AsyncIterator
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
import tarfile
|
||||
@@ -15,7 +15,12 @@ from homeassistant.components.backup import AddonInfo, AgentBackup, Folder
|
||||
from homeassistant.components.backup.const import DATA_MANAGER, DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .common import TEST_BACKUP_ABC123, BackupAgentTest, setup_backup_integration
|
||||
from .common import (
|
||||
TEST_BACKUP_ABC123,
|
||||
BackupAgentTest,
|
||||
aiter_from_iter,
|
||||
setup_backup_integration,
|
||||
)
|
||||
|
||||
from tests.common import MockUser, get_fixture_path
|
||||
from tests.typing import ClientSessionGenerator
|
||||
@@ -35,6 +40,9 @@ async def test_downloading_local_backup(
|
||||
"homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path",
|
||||
),
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch(
|
||||
"homeassistant.components.backup.http.FileResponse",
|
||||
@@ -73,9 +81,14 @@ async def test_downloading_local_encrypted_backup_file_not_found(
|
||||
await setup_backup_integration(hass)
|
||||
client = await hass_client()
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.backup.backup.CoreLocalBackupAgent.async_get_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.backup.backup.CoreLocalBackupAgent.get_backup_path",
|
||||
),
|
||||
):
|
||||
resp = await client.get(
|
||||
"/api/backup/download/abc123?agent_id=backup.local&password=blah"
|
||||
@@ -93,12 +106,6 @@ async def test_downloading_local_encrypted_backup(
|
||||
await _test_downloading_encrypted_backup(hass_client, "backup.local")
|
||||
|
||||
|
||||
async def aiter_from_iter(iterable: Iterable) -> AsyncIterator:
|
||||
"""Convert an iterable to an async iterator."""
|
||||
for i in iterable:
|
||||
yield i
|
||||
|
||||
|
||||
@patch.object(BackupAgentTest, "async_download_backup")
|
||||
async def test_downloading_remote_encrypted_backup(
|
||||
download_mock,
|
||||
|
||||
@@ -54,6 +54,8 @@ from .common import (
|
||||
LOCAL_AGENT_ID,
|
||||
TEST_BACKUP_ABC123,
|
||||
TEST_BACKUP_DEF456,
|
||||
TEST_BACKUP_PATH_ABC123,
|
||||
TEST_BACKUP_PATH_DEF456,
|
||||
BackupAgentTest,
|
||||
setup_backup_platform,
|
||||
)
|
||||
@@ -89,6 +91,15 @@ def generate_backup_id_fixture() -> Generator[MagicMock]:
|
||||
yield mock
|
||||
|
||||
|
||||
def mock_read_backup(backup_path: Path) -> AgentBackup:
|
||||
"""Mock read backup."""
|
||||
mock_backups = {
|
||||
"abc123": TEST_BACKUP_ABC123,
|
||||
"custom_def456": TEST_BACKUP_DEF456,
|
||||
}
|
||||
return mock_backups[backup_path.stem]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_backup_generation")
|
||||
async def test_create_backup_service(
|
||||
hass: HomeAssistant,
|
||||
@@ -1311,7 +1322,11 @@ class LocalBackupAgentTest(BackupAgentTest, LocalBackupAgent):
|
||||
"""Local backup agent."""
|
||||
|
||||
def get_backup_path(self, backup_id: str) -> Path:
|
||||
"""Return the local path to a backup."""
|
||||
"""Return the local path to an existing backup."""
|
||||
return Path("test.tar")
|
||||
|
||||
def get_new_backup_path(self, backup: AgentBackup) -> Path:
|
||||
"""Return the local path to a new backup."""
|
||||
return Path("test.tar")
|
||||
|
||||
|
||||
@@ -2023,10 +2038,6 @@ async def test_receive_backup_file_write_error(
|
||||
|
||||
with (
|
||||
patch("pathlib.Path.open", open_mock),
|
||||
patch(
|
||||
"homeassistant.components.backup.manager.read_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
),
|
||||
):
|
||||
resp = await client.post(
|
||||
"/api/backup/upload?agent_id=test.remote",
|
||||
@@ -2375,18 +2386,61 @@ async def test_receive_backup_file_read_error(
|
||||
|
||||
@pytest.mark.usefixtures("path_glob")
|
||||
@pytest.mark.parametrize(
|
||||
("agent_id", "password_param", "restore_database", "restore_homeassistant", "dir"),
|
||||
(
|
||||
"agent_id",
|
||||
"backup_id",
|
||||
"password_param",
|
||||
"backup_path",
|
||||
"restore_database",
|
||||
"restore_homeassistant",
|
||||
"dir",
|
||||
),
|
||||
[
|
||||
(LOCAL_AGENT_ID, {}, True, False, "backups"),
|
||||
(LOCAL_AGENT_ID, {"password": "abc123"}, False, True, "backups"),
|
||||
("test.remote", {}, True, True, "tmp_backups"),
|
||||
(
|
||||
LOCAL_AGENT_ID,
|
||||
TEST_BACKUP_ABC123.backup_id,
|
||||
{},
|
||||
TEST_BACKUP_PATH_ABC123,
|
||||
True,
|
||||
False,
|
||||
"backups",
|
||||
),
|
||||
(
|
||||
LOCAL_AGENT_ID,
|
||||
TEST_BACKUP_DEF456.backup_id,
|
||||
{},
|
||||
TEST_BACKUP_PATH_DEF456,
|
||||
True,
|
||||
False,
|
||||
"backups",
|
||||
),
|
||||
(
|
||||
LOCAL_AGENT_ID,
|
||||
TEST_BACKUP_ABC123.backup_id,
|
||||
{"password": "abc123"},
|
||||
TEST_BACKUP_PATH_ABC123,
|
||||
False,
|
||||
True,
|
||||
"backups",
|
||||
),
|
||||
(
|
||||
"test.remote",
|
||||
TEST_BACKUP_ABC123.backup_id,
|
||||
{},
|
||||
TEST_BACKUP_PATH_ABC123,
|
||||
True,
|
||||
True,
|
||||
"tmp_backups",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_restore_backup(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
agent_id: str,
|
||||
backup_id: str,
|
||||
password_param: dict[str, str],
|
||||
backup_path: Path,
|
||||
restore_database: bool,
|
||||
restore_homeassistant: bool,
|
||||
dir: str,
|
||||
@@ -2426,14 +2480,14 @@ async def test_restore_backup(
|
||||
patch.object(remote_agent, "async_download_backup") as download_mock,
|
||||
patch(
|
||||
"homeassistant.components.backup.backup.read_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
side_effect=mock_read_backup,
|
||||
),
|
||||
):
|
||||
download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
|
||||
await ws_client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/restore",
|
||||
"backup_id": TEST_BACKUP_ABC123.backup_id,
|
||||
"backup_id": backup_id,
|
||||
"agent_id": agent_id,
|
||||
"restore_database": restore_database,
|
||||
"restore_homeassistant": restore_homeassistant,
|
||||
@@ -2473,17 +2527,17 @@ async def test_restore_backup(
|
||||
result = await ws_client.receive_json()
|
||||
assert result["success"] is True
|
||||
|
||||
backup_path = f"{hass.config.path()}/{dir}/abc123.tar"
|
||||
full_backup_path = f"{hass.config.path()}/{dir}/{backup_path.name}"
|
||||
expected_restore_file = json.dumps(
|
||||
{
|
||||
"path": backup_path,
|
||||
"path": full_backup_path,
|
||||
"password": password,
|
||||
"remove_after_restore": agent_id != LOCAL_AGENT_ID,
|
||||
"restore_database": restore_database,
|
||||
"restore_homeassistant": restore_homeassistant,
|
||||
}
|
||||
)
|
||||
validate_password_mock.assert_called_once_with(Path(backup_path), password)
|
||||
validate_password_mock.assert_called_once_with(Path(full_backup_path), password)
|
||||
assert mocked_write_text.call_args[0][0] == expected_restore_file
|
||||
assert mocked_service_call.called
|
||||
|
||||
@@ -2533,7 +2587,7 @@ async def test_restore_backup_wrong_password(
|
||||
patch.object(remote_agent, "async_download_backup") as download_mock,
|
||||
patch(
|
||||
"homeassistant.components.backup.backup.read_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
side_effect=mock_read_backup,
|
||||
),
|
||||
):
|
||||
download_mock.return_value.__aiter__.return_value = iter((b"backup data",))
|
||||
@@ -2581,8 +2635,8 @@ async def test_restore_backup_wrong_password(
|
||||
("parameters", "expected_error", "expected_reason"),
|
||||
[
|
||||
(
|
||||
{"backup_id": TEST_BACKUP_DEF456.backup_id},
|
||||
f"Backup def456 not found in agent {LOCAL_AGENT_ID}",
|
||||
{"backup_id": "no_such_backup"},
|
||||
f"Backup no_such_backup not found in agent {LOCAL_AGENT_ID}",
|
||||
"backup_manager_error",
|
||||
),
|
||||
(
|
||||
@@ -2629,7 +2683,7 @@ async def test_restore_backup_wrong_parameters(
|
||||
patch("homeassistant.core.ServiceRegistry.async_call") as mocked_service_call,
|
||||
patch(
|
||||
"homeassistant.components.backup.backup.read_backup",
|
||||
return_value=TEST_BACKUP_ABC123,
|
||||
side_effect=mock_read_backup,
|
||||
),
|
||||
):
|
||||
await ws_client.send_json_auto_id(
|
||||
|
||||
@@ -426,7 +426,7 @@ async def test_restore_history_from_dbus(
|
||||
address: AdvertisementHistory(
|
||||
ble_device,
|
||||
generate_advertisement_data(local_name="name"),
|
||||
HCI0_SOURCE_ADDRESS,
|
||||
"hci0",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -438,6 +438,8 @@ async def test_restore_history_from_dbus(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert bluetooth.async_ble_device_from_address(hass, address) is ble_device
|
||||
info = bluetooth.async_last_service_info(hass, address, False)
|
||||
assert info.source == "00:00:00:00:00:01"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("one_adapter")
|
||||
|
||||
@@ -535,6 +535,7 @@ def supervisor_client() -> Generator[AsyncMock]:
|
||||
supervisor_client.discovery = AsyncMock()
|
||||
supervisor_client.homeassistant = AsyncMock()
|
||||
supervisor_client.host = AsyncMock()
|
||||
supervisor_client.jobs = AsyncMock()
|
||||
supervisor_client.mounts.info.return_value = mounts_info_mock
|
||||
supervisor_client.os = AsyncMock()
|
||||
supervisor_client.resolution = AsyncMock()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pyenphase.exceptions import EnvoyError
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -16,6 +17,7 @@ from homeassistant.const import (
|
||||
STATE_ON,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import setup_integration
|
||||
@@ -112,6 +114,46 @@ async def test_switch_grid_operation(
|
||||
mock_envoy.go_off_grid.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_envoy", ["envoy_metered_batt_relay"], indirect=True)
|
||||
async def test_switch_grid_operation_with_error(
|
||||
hass: HomeAssistant,
|
||||
mock_envoy: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test switch platform operation for grid switches when error occurs."""
|
||||
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]):
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
sn = mock_envoy.data.enpower.serial_number
|
||||
test_entity = f"{Platform.SWITCH}.enpower_{sn}_grid_enabled"
|
||||
|
||||
mock_envoy.go_off_grid.side_effect = EnvoyError("Test")
|
||||
mock_envoy.go_on_grid.side_effect = EnvoyError("Test")
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=f"Failed to execute async_turn_off for {test_entity}, host",
|
||||
):
|
||||
# test grid status switch operation
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: test_entity},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=f"Failed to execute async_turn_on for {test_entity}, host",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: test_entity},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_envoy", "use_serial"),
|
||||
[
|
||||
@@ -165,6 +207,53 @@ async def test_switch_charge_from_grid_operation(
|
||||
mock_envoy.disable_charge_from_grid.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_envoy", "use_serial"),
|
||||
[
|
||||
("envoy_metered_batt_relay", "enpower_654321"),
|
||||
("envoy_eu_batt", "envoy_1234"),
|
||||
],
|
||||
indirect=["mock_envoy"],
|
||||
)
|
||||
async def test_switch_charge_from_grid_operation_with_error(
|
||||
hass: HomeAssistant,
|
||||
mock_envoy: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
use_serial: str,
|
||||
) -> None:
|
||||
"""Test switch platform operation for charge from grid switches."""
|
||||
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]):
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
test_entity = f"{Platform.SWITCH}.{use_serial}_charge_from_grid"
|
||||
|
||||
mock_envoy.disable_charge_from_grid.side_effect = EnvoyError("Test")
|
||||
mock_envoy.enable_charge_from_grid.side_effect = EnvoyError("Test")
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=f"Failed to execute async_turn_off for {test_entity}, host",
|
||||
):
|
||||
# test grid status switch operation
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: test_entity},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=f"Failed to execute async_turn_on for {test_entity}, host",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: test_entity},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_envoy", "entity_states"),
|
||||
[
|
||||
@@ -232,3 +321,51 @@ async def test_switch_relay_operation(
|
||||
assert mock_envoy.close_dry_contact.await_count == close_count
|
||||
mock_envoy.open_dry_contact.reset_mock()
|
||||
mock_envoy.close_dry_contact.reset_mock()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_envoy", "relay"),
|
||||
[("envoy_metered_batt_relay", "NC1")],
|
||||
indirect=["mock_envoy"],
|
||||
)
|
||||
async def test_switch_relay_operation_with_error(
|
||||
hass: HomeAssistant,
|
||||
mock_envoy: AsyncMock,
|
||||
config_entry: MockConfigEntry,
|
||||
relay: str,
|
||||
) -> None:
|
||||
"""Test enphase_envoy switch relay entities operation."""
|
||||
with patch("homeassistant.components.enphase_envoy.PLATFORMS", [Platform.SWITCH]):
|
||||
await setup_integration(hass, config_entry)
|
||||
|
||||
entity_base = f"{Platform.SWITCH}."
|
||||
|
||||
assert (dry_contact := mock_envoy.data.dry_contact_settings[relay])
|
||||
assert (name := dry_contact.load_name.lower().replace(" ", "_"))
|
||||
|
||||
test_entity = f"{entity_base}{name}"
|
||||
|
||||
mock_envoy.close_dry_contact.side_effect = EnvoyError("Test")
|
||||
mock_envoy.open_dry_contact.side_effect = EnvoyError("Test")
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=f"Failed to execute async_turn_off for {test_entity}, host",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_OFF,
|
||||
{ATTR_ENTITY_ID: test_entity},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match=f"Failed to execute async_turn_on for {test_entity}, host",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{ATTR_ENTITY_ID: test_entity},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ from io import StringIO
|
||||
import os
|
||||
from typing import Any
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from aiohasupervisor.exceptions import (
|
||||
SupervisorBadRequestError,
|
||||
@@ -21,6 +22,7 @@ from aiohasupervisor.exceptions import (
|
||||
)
|
||||
from aiohasupervisor.models import (
|
||||
backups as supervisor_backups,
|
||||
jobs as supervisor_jobs,
|
||||
mounts as supervisor_mounts,
|
||||
)
|
||||
from aiohasupervisor.models.mounts import MountsInfo
|
||||
@@ -35,7 +37,11 @@ from homeassistant.components.backup import (
|
||||
Folder,
|
||||
)
|
||||
from homeassistant.components.hassio import DOMAIN
|
||||
from homeassistant.components.hassio.backup import LOCATION_CLOUD_BACKUP, LOCATION_LOCAL
|
||||
from homeassistant.components.hassio.backup import (
|
||||
LOCATION_CLOUD_BACKUP,
|
||||
LOCATION_LOCAL,
|
||||
RESTORE_JOB_ID_ENV,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -245,6 +251,78 @@ TEST_BACKUP_DETAILS_4 = supervisor_backups.BackupComplete(
|
||||
type=TEST_BACKUP.type,
|
||||
)
|
||||
|
||||
TEST_BACKUP_5 = supervisor_backups.Backup(
|
||||
compressed=False,
|
||||
content=supervisor_backups.BackupContent(
|
||||
addons=["ssl"],
|
||||
folders=[supervisor_backups.Folder.SHARE],
|
||||
homeassistant=True,
|
||||
),
|
||||
date=datetime.fromisoformat("1970-01-01T00:00:00Z"),
|
||||
location=LOCATION_CLOUD_BACKUP,
|
||||
location_attributes={
|
||||
LOCATION_CLOUD_BACKUP: supervisor_backups.BackupLocationAttributes(
|
||||
protected=False, size_bytes=1048576
|
||||
)
|
||||
},
|
||||
locations={LOCATION_CLOUD_BACKUP},
|
||||
name="Test",
|
||||
protected=False,
|
||||
size=1.0,
|
||||
size_bytes=1048576,
|
||||
slug="abc123",
|
||||
type=supervisor_backups.BackupType.PARTIAL,
|
||||
)
|
||||
TEST_BACKUP_DETAILS_5 = supervisor_backups.BackupComplete(
|
||||
addons=[
|
||||
supervisor_backups.BackupAddon(
|
||||
name="Terminal & SSH",
|
||||
size=0.0,
|
||||
slug="core_ssh",
|
||||
version="9.14.0",
|
||||
)
|
||||
],
|
||||
compressed=TEST_BACKUP_5.compressed,
|
||||
date=TEST_BACKUP_5.date,
|
||||
extra=None,
|
||||
folders=[supervisor_backups.Folder.SHARE],
|
||||
homeassistant_exclude_database=False,
|
||||
homeassistant="2024.12.0",
|
||||
location=TEST_BACKUP_5.location,
|
||||
location_attributes=TEST_BACKUP_5.location_attributes,
|
||||
locations=TEST_BACKUP_5.locations,
|
||||
name=TEST_BACKUP_5.name,
|
||||
protected=TEST_BACKUP_5.protected,
|
||||
repositories=[],
|
||||
size=TEST_BACKUP_5.size,
|
||||
size_bytes=TEST_BACKUP_5.size_bytes,
|
||||
slug=TEST_BACKUP_5.slug,
|
||||
supervisor_version="2024.11.2",
|
||||
type=TEST_BACKUP_5.type,
|
||||
)
|
||||
|
||||
TEST_JOB_ID = "d17bd02be1f0437fa7264b16d38f700e"
|
||||
TEST_JOB_NOT_DONE = supervisor_jobs.Job(
|
||||
name="backup_manager_partial_backup",
|
||||
reference="1ef41507",
|
||||
uuid=UUID(TEST_JOB_ID),
|
||||
progress=0.0,
|
||||
stage="copy_additional_locations",
|
||||
done=False,
|
||||
errors=[],
|
||||
child_jobs=[],
|
||||
)
|
||||
TEST_JOB_DONE = supervisor_jobs.Job(
|
||||
name="backup_manager_partial_backup",
|
||||
reference="1ef41507",
|
||||
uuid=UUID(TEST_JOB_ID),
|
||||
progress=0.0,
|
||||
stage="copy_additional_locations",
|
||||
done=True,
|
||||
errors=[],
|
||||
child_jobs=[],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixture_supervisor_environ() -> Generator[None]:
|
||||
@@ -466,7 +544,7 @@ async def test_agent_download(
|
||||
hass_client: ClientSessionGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test agent download backup, when cloud user is logged in."""
|
||||
"""Test agent download backup."""
|
||||
client = await hass_client()
|
||||
backup_id = "abc123"
|
||||
supervisor_client.backups.list.return_value = [TEST_BACKUP]
|
||||
@@ -490,7 +568,7 @@ async def test_agent_download_unavailable_backup(
|
||||
hass_client: ClientSessionGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test agent download backup, when cloud user is logged in."""
|
||||
"""Test agent download backup which does not exist."""
|
||||
client = await hass_client()
|
||||
backup_id = "abc123"
|
||||
supervisor_client.backups.list.return_value = [TEST_BACKUP_3]
|
||||
@@ -552,6 +630,91 @@ async def test_agent_upload(
|
||||
supervisor_client.backups.remove_backup.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_integration")
|
||||
async def test_agent_get_backup(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test agent get backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
backup_id = "abc123"
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/details",
|
||||
"backup_id": backup_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {
|
||||
"agent_errors": {},
|
||||
"backup": {
|
||||
"addons": [
|
||||
{"name": "Terminal & SSH", "slug": "core_ssh", "version": "9.14.0"}
|
||||
],
|
||||
"agents": {"hassio.local": {"protected": False, "size": 1048576}},
|
||||
"backup_id": "abc123",
|
||||
"database_included": True,
|
||||
"date": "1970-01-01T00:00:00+00:00",
|
||||
"failed_agent_ids": [],
|
||||
"folders": ["share"],
|
||||
"homeassistant_included": True,
|
||||
"homeassistant_version": "2024.12.0",
|
||||
"name": "Test",
|
||||
"with_automatic_settings": None,
|
||||
},
|
||||
}
|
||||
supervisor_client.backups.backup_info.assert_called_once_with(backup_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("backup_info_side_effect", "expected_response"),
|
||||
[
|
||||
(
|
||||
SupervisorBadRequestError("blah"),
|
||||
{
|
||||
"success": False,
|
||||
"error": {"code": "unknown_error", "message": "Unknown error"},
|
||||
},
|
||||
),
|
||||
(
|
||||
SupervisorNotFoundError(),
|
||||
{
|
||||
"success": True,
|
||||
"result": {"agent_errors": {}, "backup": None},
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_agent_get_backup_with_error(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
backup_info_side_effect: Exception,
|
||||
expected_response: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test agent get backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
backup_id = "abc123"
|
||||
|
||||
supervisor_client.backups.backup_info.side_effect = backup_info_side_effect
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/details",
|
||||
"backup_id": backup_id,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response == {"id": 1, "type": "result"} | expected_response
|
||||
supervisor_client.backups.backup_info.assert_called_once_with(backup_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_integration")
|
||||
async def test_agent_delete_backup(
|
||||
hass: HomeAssistant,
|
||||
@@ -588,13 +751,6 @@ async def test_agent_delete_backup(
|
||||
"error": {"code": "unknown_error", "message": "Unknown error"},
|
||||
},
|
||||
),
|
||||
(
|
||||
SupervisorBadRequestError("Backup does not exist"),
|
||||
{
|
||||
"success": True,
|
||||
"result": {"agent_errors": {}},
|
||||
},
|
||||
),
|
||||
(
|
||||
SupervisorNotFoundError(),
|
||||
{
|
||||
@@ -757,8 +913,9 @@ async def test_reader_writer_create(
|
||||
) -> None:
|
||||
"""Test generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/subscribe_events"})
|
||||
response = await client.receive_json()
|
||||
@@ -780,7 +937,7 @@ async def test_reader_writer_create(
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": "abc123"}
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
supervisor_client.backups.partial_backup.assert_called_once_with(
|
||||
expected_supervisor_options
|
||||
@@ -791,7 +948,7 @@ async def test_reader_writer_create(
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": "abc123", "reference": "test_slug"},
|
||||
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -821,6 +978,291 @@ async def test_reader_writer_create(
|
||||
assert response["event"] == {"manager_state": "idle"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_integration")
|
||||
async def test_reader_writer_create_job_done(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test generating a backup, and backup job finishes early."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/subscribe_events"})
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {"manager_state": "idle"}
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{"type": "backup/generate", "agent_ids": ["hassio.local"], "name": "Test"}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": None,
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
supervisor_client.backups.partial_backup.assert_called_once_with(
|
||||
DEFAULT_BACKUP_OPTIONS
|
||||
)
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "upload_to_agents",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": None,
|
||||
"state": "completed",
|
||||
}
|
||||
|
||||
supervisor_client.backups.download_backup.assert_not_called()
|
||||
supervisor_client.backups.remove_backup.assert_not_called()
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {"manager_state": "idle"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client")
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"commands",
|
||||
"password",
|
||||
"agent_ids",
|
||||
"password_sent_to_supervisor",
|
||||
"create_locations",
|
||||
"create_protected",
|
||||
"upload_locations",
|
||||
),
|
||||
[
|
||||
(
|
||||
[],
|
||||
None,
|
||||
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
|
||||
None,
|
||||
[None, "share1", "share2", "share3"],
|
||||
False,
|
||||
[],
|
||||
),
|
||||
(
|
||||
[],
|
||||
"hunter2",
|
||||
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
|
||||
"hunter2",
|
||||
[None, "share1", "share2", "share3"],
|
||||
True,
|
||||
[],
|
||||
),
|
||||
(
|
||||
[
|
||||
{
|
||||
"type": "backup/config/update",
|
||||
"agents": {
|
||||
"hassio.local": {"protected": False},
|
||||
},
|
||||
}
|
||||
],
|
||||
"hunter2",
|
||||
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
|
||||
"hunter2",
|
||||
["share1", "share2", "share3"],
|
||||
True,
|
||||
[None],
|
||||
),
|
||||
(
|
||||
[
|
||||
{
|
||||
"type": "backup/config/update",
|
||||
"agents": {
|
||||
"hassio.local": {"protected": False},
|
||||
"hassio.share1": {"protected": False},
|
||||
},
|
||||
}
|
||||
],
|
||||
"hunter2",
|
||||
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
|
||||
"hunter2",
|
||||
["share2", "share3"],
|
||||
True,
|
||||
[None, "share1"],
|
||||
),
|
||||
(
|
||||
[
|
||||
{
|
||||
"type": "backup/config/update",
|
||||
"agents": {
|
||||
"hassio.local": {"protected": False},
|
||||
"hassio.share1": {"protected": False},
|
||||
"hassio.share2": {"protected": False},
|
||||
},
|
||||
}
|
||||
],
|
||||
"hunter2",
|
||||
["hassio.local", "hassio.share1", "hassio.share2", "hassio.share3"],
|
||||
None,
|
||||
[None, "share1", "share2"],
|
||||
True,
|
||||
["share3"],
|
||||
),
|
||||
(
|
||||
[
|
||||
{
|
||||
"type": "backup/config/update",
|
||||
"agents": {
|
||||
"hassio.local": {"protected": False},
|
||||
},
|
||||
}
|
||||
],
|
||||
"hunter2",
|
||||
["hassio.local"],
|
||||
None,
|
||||
[None],
|
||||
False,
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_reader_writer_create_per_agent_encryption(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
commands: dict[str, Any],
|
||||
password: str | None,
|
||||
agent_ids: list[str],
|
||||
password_sent_to_supervisor: str | None,
|
||||
create_locations: list[str | None],
|
||||
create_protected: bool,
|
||||
upload_locations: list[str | None],
|
||||
) -> None:
|
||||
"""Test generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
mounts = MountsInfo(
|
||||
default_backup_mount=None,
|
||||
mounts=[
|
||||
supervisor_mounts.CIFSMountResponse(
|
||||
share=f"share{i}",
|
||||
name=f"share{i}",
|
||||
read_only=False,
|
||||
state=supervisor_mounts.MountState.ACTIVE,
|
||||
user_path=f"share{i}",
|
||||
usage=supervisor_mounts.MountUsage.BACKUP,
|
||||
server=f"share{i}",
|
||||
type=supervisor_mounts.MountType.CIFS,
|
||||
)
|
||||
for i in range(1, 4)
|
||||
],
|
||||
)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.return_value = replace(
|
||||
TEST_BACKUP_DETAILS,
|
||||
locations=create_locations,
|
||||
location_attributes={
|
||||
location or LOCATION_LOCAL: supervisor_backups.BackupLocationAttributes(
|
||||
protected=create_protected,
|
||||
size_bytes=TEST_BACKUP_DETAILS.size_bytes,
|
||||
)
|
||||
for location in create_locations
|
||||
},
|
||||
)
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
|
||||
supervisor_client.mounts.info.return_value = mounts
|
||||
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
|
||||
|
||||
for command in commands:
|
||||
await client.send_json_auto_id(command)
|
||||
result = await client.receive_json()
|
||||
assert result["success"] is True
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/subscribe_events"})
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {"manager_state": "idle"}
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/generate",
|
||||
"agent_ids": agent_ids,
|
||||
"name": "Test",
|
||||
"password": password,
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": None,
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
supervisor_client.backups.partial_backup.assert_called_once_with(
|
||||
replace(
|
||||
DEFAULT_BACKUP_OPTIONS,
|
||||
password=password_sent_to_supervisor,
|
||||
location=create_locations,
|
||||
)
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
|
||||
},
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": "upload_to_agents",
|
||||
"state": "in_progress",
|
||||
}
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
"manager_state": "create_backup",
|
||||
"reason": None,
|
||||
"stage": None,
|
||||
"state": "completed",
|
||||
}
|
||||
|
||||
assert len(supervisor_client.backups.upload_backup.mock_calls) == len(
|
||||
upload_locations
|
||||
)
|
||||
for call in supervisor_client.backups.upload_backup.mock_calls:
|
||||
upload_call_locations: set = call.args[1].location
|
||||
assert len(upload_call_locations) == 1
|
||||
assert upload_call_locations.pop() in upload_locations
|
||||
supervisor_client.backups.remove_backup.assert_not_called()
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {"manager_state": "idle"}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "error_code", "error_message", "expected_reason"),
|
||||
@@ -896,7 +1338,8 @@ async def test_reader_writer_create_missing_reference_error(
|
||||
) -> None:
|
||||
"""Test missing reference error when generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/subscribe_events"})
|
||||
response = await client.receive_json()
|
||||
@@ -917,7 +1360,7 @@ async def test_reader_writer_create_missing_reference_error(
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": "abc123"}
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
assert supervisor_client.backups.partial_backup.call_count == 1
|
||||
|
||||
@@ -926,7 +1369,7 @@ async def test_reader_writer_create_missing_reference_error(
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": "abc123"},
|
||||
"data": {"done": True, "uuid": TEST_JOB_ID},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -968,8 +1411,9 @@ async def test_reader_writer_create_download_remove_error(
|
||||
) -> None:
|
||||
"""Test download and remove error when generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
|
||||
method_mock = getattr(supervisor_client.backups, method)
|
||||
method_mock.side_effect = exception
|
||||
|
||||
@@ -1002,7 +1446,7 @@ async def test_reader_writer_create_download_remove_error(
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": "abc123"}
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
assert supervisor_client.backups.partial_backup.call_count == 1
|
||||
|
||||
@@ -1011,7 +1455,7 @@ async def test_reader_writer_create_download_remove_error(
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": "abc123", "reference": "test_slug"},
|
||||
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1054,8 +1498,9 @@ async def test_reader_writer_create_info_error(
|
||||
) -> None:
|
||||
"""Test backup info error when generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.side_effect = exception
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
|
||||
|
||||
remote_agent = BackupAgentTest("remote")
|
||||
await _setup_backup_platform(
|
||||
@@ -1086,7 +1531,7 @@ async def test_reader_writer_create_info_error(
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": "abc123"}
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
assert supervisor_client.backups.partial_backup.call_count == 1
|
||||
|
||||
@@ -1095,7 +1540,7 @@ async def test_reader_writer_create_info_error(
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": "abc123", "reference": "test_slug"},
|
||||
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1128,8 +1573,9 @@ async def test_reader_writer_create_remote_backup(
|
||||
) -> None:
|
||||
"""Test generating a backup which will be uploaded to a remote agent."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_NOT_DONE
|
||||
|
||||
remote_agent = BackupAgentTest("remote")
|
||||
await _setup_backup_platform(
|
||||
@@ -1160,10 +1606,10 @@ async def test_reader_writer_create_remote_backup(
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
assert response["result"] == {"backup_job_id": "abc123"}
|
||||
assert response["result"] == {"backup_job_id": TEST_JOB_ID}
|
||||
|
||||
supervisor_client.backups.partial_backup.assert_called_once_with(
|
||||
replace(DEFAULT_BACKUP_OPTIONS, location=LOCATION_CLOUD_BACKUP),
|
||||
replace(DEFAULT_BACKUP_OPTIONS, location=[LOCATION_CLOUD_BACKUP]),
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
@@ -1171,7 +1617,7 @@ async def test_reader_writer_create_remote_backup(
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": "abc123", "reference": "test_slug"},
|
||||
"data": {"done": True, "uuid": TEST_JOB_ID, "reference": "test_slug"},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -1230,7 +1676,7 @@ async def test_reader_writer_create_wrong_parameters(
|
||||
) -> None:
|
||||
"""Test generating a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.partial_backup.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/subscribe_events"})
|
||||
@@ -1280,7 +1726,7 @@ async def test_agent_receive_remote_backup(
|
||||
"""Test receiving a backup which will be uploaded to a remote agent."""
|
||||
client = await hass_client()
|
||||
backup_id = "test-backup"
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS_5
|
||||
supervisor_client.backups.upload_backup.return_value = "test_slug"
|
||||
test_backup = AgentBackup(
|
||||
addons=[AddonInfo(name="Test", slug="test", version="1.0.0")],
|
||||
@@ -1334,17 +1780,33 @@ async def test_agent_receive_remote_backup(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("get_job_result", "supervisor_events"),
|
||||
[
|
||||
(
|
||||
TEST_JOB_NOT_DONE,
|
||||
[{"event": "job", "data": {"done": True, "uuid": TEST_JOB_ID}}],
|
||||
),
|
||||
(
|
||||
TEST_JOB_DONE,
|
||||
[],
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("hassio_client", "setup_integration")
|
||||
async def test_reader_writer_restore(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
get_job_result: supervisor_jobs.Job,
|
||||
supervisor_events: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Test restoring a backup."""
|
||||
client = await hass_ws_client(hass)
|
||||
supervisor_client.backups.partial_restore.return_value.job_id = "abc123"
|
||||
supervisor_client.backups.partial_restore.return_value.job_id = TEST_JOB_ID
|
||||
supervisor_client.backups.list.return_value = [TEST_BACKUP]
|
||||
supervisor_client.backups.backup_info.return_value = TEST_BACKUP_DETAILS
|
||||
supervisor_client.jobs.get_job.return_value = get_job_result
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/subscribe_events"})
|
||||
response = await client.receive_json()
|
||||
@@ -1377,17 +1839,10 @@ async def test_reader_writer_restore(
|
||||
),
|
||||
)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {"done": True, "uuid": "abc123"},
|
||||
},
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
for event in supervisor_events:
|
||||
await client.send_json_auto_id({"type": "supervisor/event", "data": event})
|
||||
response = await client.receive_json()
|
||||
assert response["success"]
|
||||
|
||||
response = await client.receive_json()
|
||||
assert response["event"] == {
|
||||
@@ -1528,3 +1983,54 @@ async def test_reader_writer_restore_wrong_parameters(
|
||||
"code": "home_assistant_error",
|
||||
"message": expected_error,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client")
|
||||
async def test_restore_progress_after_restart(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test restore backup progress after restart."""
|
||||
|
||||
supervisor_client.jobs.get_job.return_value = TEST_JOB_DONE
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}):
|
||||
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["last_non_idle_event"] == {
|
||||
"manager_state": "restore_backup",
|
||||
"reason": "",
|
||||
"stage": None,
|
||||
"state": "completed",
|
||||
}
|
||||
assert response["result"]["state"] == "idle"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("hassio_client")
|
||||
async def test_restore_progress_after_restart_unknown_job(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
supervisor_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test restore backup progress after restart."""
|
||||
|
||||
supervisor_client.jobs.get_job.side_effect = SupervisorError
|
||||
|
||||
with patch.dict(os.environ, MOCK_ENVIRON | {RESTORE_JOB_ID_ENV: TEST_JOB_ID}):
|
||||
assert await async_setup_component(hass, BACKUP_DOMAIN, {BACKUP_DOMAIN: {}})
|
||||
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id({"type": "backup/info"})
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"]["last_non_idle_event"] is None
|
||||
assert response["result"]["state"] == "idle"
|
||||
|
||||
@@ -1,18 +1,32 @@
|
||||
"""Test fixtures for home_connect."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||
import copy
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, PropertyMock, patch
|
||||
from typing import Any, cast
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from homeconnect.api import HomeConnectAppliance, HomeConnectError
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfAvailablePrograms,
|
||||
ArrayOfEvents,
|
||||
ArrayOfHomeAppliances,
|
||||
ArrayOfSettings,
|
||||
ArrayOfStatus,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
Option,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectApiError, HomeConnectError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.home_connect import update_all_devices
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -20,12 +34,17 @@ from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
MOCK_APPLIANCES_PROPERTIES = {
|
||||
x["name"]: x
|
||||
for x in load_json_object_fixture("home_connect/appliances.json")["data"][
|
||||
"homeappliances"
|
||||
]
|
||||
}
|
||||
MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict(
|
||||
load_json_object_fixture("home_connect/appliances.json")["data"]
|
||||
)
|
||||
MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture(
|
||||
"home_connect/programs-available.json"
|
||||
)
|
||||
MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json")
|
||||
MOCK_STATUS = ArrayOfStatus.from_dict(
|
||||
load_json_object_fixture("home_connect/status.json")["data"]
|
||||
)
|
||||
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
@@ -102,32 +121,23 @@ def platforms() -> list[Platform]:
|
||||
return []
|
||||
|
||||
|
||||
async def bypass_throttle(hass: HomeAssistant, config_entry: MockConfigEntry):
|
||||
"""Add kwarg to disable throttle."""
|
||||
await update_all_devices(hass, config_entry, no_throttle=True)
|
||||
|
||||
|
||||
@pytest.fixture(name="bypass_throttle")
|
||||
def mock_bypass_throttle() -> Generator[None]:
|
||||
"""Fixture to bypass the throttle decorator in __init__."""
|
||||
with patch(
|
||||
"homeassistant.components.home_connect.update_all_devices",
|
||||
side_effect=bypass_throttle,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(name="integration_setup")
|
||||
async def mock_integration_setup(
|
||||
hass: HomeAssistant,
|
||||
platforms: list[Platform],
|
||||
config_entry: MockConfigEntry,
|
||||
) -> Callable[[], Awaitable[bool]]:
|
||||
) -> Callable[[MagicMock], Awaitable[bool]]:
|
||||
"""Fixture to set up the integration."""
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
async def run() -> bool:
|
||||
with patch("homeassistant.components.home_connect.PLATFORMS", platforms):
|
||||
async def run(client: MagicMock) -> bool:
|
||||
with (
|
||||
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
|
||||
patch(
|
||||
"homeassistant.components.home_connect.HomeConnectClient"
|
||||
) as client_mock,
|
||||
):
|
||||
client_mock.return_value = client
|
||||
result = await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
return result
|
||||
@@ -135,125 +145,205 @@ async def mock_integration_setup(
|
||||
return run
|
||||
|
||||
|
||||
@pytest.fixture(name="get_appliances")
|
||||
def mock_get_appliances() -> Generator[MagicMock]:
|
||||
"""Mock ConfigEntryAuth parent (HomeAssistantAPI) method."""
|
||||
with patch(
|
||||
"homeassistant.components.home_connect.api.ConfigEntryAuth.get_appliances",
|
||||
) as mock:
|
||||
yield mock
|
||||
def _get_set_program_side_effect(
|
||||
event_queue: asyncio.Queue[list[EventMessage]], event_key: EventKey
|
||||
):
|
||||
"""Set program side effect."""
|
||||
|
||||
async def set_program_side_effect(ha_id: str, *_, **kwargs) -> None:
|
||||
await event_queue.put(
|
||||
[
|
||||
EventMessage(
|
||||
ha_id,
|
||||
EventType.NOTIFY,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=str(kwargs["program_key"]),
|
||||
),
|
||||
*[
|
||||
Event(
|
||||
key=(option_event := EventKey(option.key)),
|
||||
raw_key=option_event.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=str(option.key),
|
||||
)
|
||||
for option in cast(
|
||||
list[Option], kwargs.get("options", [])
|
||||
)
|
||||
],
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return set_program_side_effect
|
||||
|
||||
|
||||
@pytest.fixture(name="appliance")
|
||||
def mock_appliance(request: pytest.FixtureRequest) -> MagicMock:
|
||||
def _get_set_key_value_side_effect(
|
||||
event_queue: asyncio.Queue[list[EventMessage]], parameter_key: str
|
||||
):
|
||||
"""Set program options side effect."""
|
||||
|
||||
async def set_key_value_side_effect(ha_id: str, *_, **kwargs) -> None:
|
||||
event_key = EventKey(kwargs[parameter_key])
|
||||
await event_queue.put(
|
||||
[
|
||||
EventMessage(
|
||||
ha_id,
|
||||
EventType.NOTIFY,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=kwargs["value"],
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
return set_key_value_side_effect
|
||||
|
||||
|
||||
async def _get_available_programs_side_effect(ha_id: str) -> ArrayOfAvailablePrograms:
|
||||
"""Get available programs."""
|
||||
appliance_type = next(
|
||||
appliance
|
||||
for appliance in MOCK_APPLIANCES.homeappliances
|
||||
if appliance.ha_id == ha_id
|
||||
).type
|
||||
if appliance_type not in MOCK_PROGRAMS:
|
||||
raise HomeConnectApiError("error.key", "error description")
|
||||
|
||||
return ArrayOfAvailablePrograms.from_dict(MOCK_PROGRAMS[appliance_type]["data"])
|
||||
|
||||
|
||||
async def _get_settings_side_effect(ha_id: str) -> ArrayOfSettings:
|
||||
"""Get settings."""
|
||||
return ArrayOfSettings.from_dict(
|
||||
MOCK_SETTINGS.get(
|
||||
next(
|
||||
appliance
|
||||
for appliance in MOCK_APPLIANCES.homeappliances
|
||||
if appliance.ha_id == ha_id
|
||||
).type,
|
||||
{},
|
||||
).get("data", {"settings": []})
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def mock_client(request: pytest.FixtureRequest) -> MagicMock:
|
||||
"""Fixture to mock Client from HomeConnect."""
|
||||
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectClient,
|
||||
)
|
||||
|
||||
event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue()
|
||||
|
||||
async def add_events(events: list[EventMessage]) -> None:
|
||||
await event_queue.put(events)
|
||||
|
||||
mock.add_events = add_events
|
||||
|
||||
async def stream_all_events() -> AsyncGenerator[EventMessage]:
|
||||
"""Mock stream_all_events."""
|
||||
while True:
|
||||
for event in await event_queue.get():
|
||||
yield event
|
||||
|
||||
mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES)
|
||||
mock.stream_all_events = stream_all_events
|
||||
mock.start_program = AsyncMock(
|
||||
side_effect=_get_set_program_side_effect(
|
||||
event_queue, EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM
|
||||
)
|
||||
)
|
||||
mock.set_selected_program = AsyncMock(
|
||||
side_effect=_get_set_program_side_effect(
|
||||
event_queue, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM
|
||||
),
|
||||
)
|
||||
mock.set_active_program_option = AsyncMock(
|
||||
side_effect=_get_set_key_value_side_effect(event_queue, "option_key"),
|
||||
)
|
||||
mock.set_selected_program_option = AsyncMock(
|
||||
side_effect=_get_set_key_value_side_effect(event_queue, "option_key"),
|
||||
)
|
||||
mock.set_setting = AsyncMock(
|
||||
side_effect=_get_set_key_value_side_effect(event_queue, "setting_key"),
|
||||
)
|
||||
mock.get_settings = AsyncMock(side_effect=_get_settings_side_effect)
|
||||
mock.get_status = AsyncMock(return_value=copy.deepcopy(MOCK_STATUS))
|
||||
mock.get_available_programs = AsyncMock(
|
||||
side_effect=_get_available_programs_side_effect
|
||||
)
|
||||
mock.put_command = AsyncMock()
|
||||
|
||||
mock.side_effect = mock
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture(name="client_with_exception")
|
||||
def mock_client_with_exception(request: pytest.FixtureRequest) -> MagicMock:
|
||||
"""Fixture to mock Client from HomeConnect that raise exceptions."""
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectClient,
|
||||
)
|
||||
|
||||
exception = HomeConnectError()
|
||||
if hasattr(request, "param") and request.param:
|
||||
exception = request.param
|
||||
|
||||
event_queue: asyncio.Queue[list[EventMessage]] = asyncio.Queue()
|
||||
|
||||
async def stream_all_events() -> AsyncGenerator[EventMessage]:
|
||||
"""Mock stream_all_events."""
|
||||
while True:
|
||||
for event in await event_queue.get():
|
||||
yield event
|
||||
|
||||
mock.get_home_appliances = AsyncMock(return_value=MOCK_APPLIANCES)
|
||||
mock.stream_all_events = stream_all_events
|
||||
|
||||
mock.start_program = AsyncMock(side_effect=exception)
|
||||
mock.stop_program = AsyncMock(side_effect=exception)
|
||||
mock.get_available_programs = AsyncMock(side_effect=exception)
|
||||
mock.set_selected_program = AsyncMock(side_effect=exception)
|
||||
mock.set_active_program_option = AsyncMock(side_effect=exception)
|
||||
mock.set_selected_program_option = AsyncMock(side_effect=exception)
|
||||
mock.set_setting = AsyncMock(side_effect=exception)
|
||||
mock.get_settings = AsyncMock(side_effect=exception)
|
||||
mock.get_setting = AsyncMock(side_effect=exception)
|
||||
mock.get_status = AsyncMock(side_effect=exception)
|
||||
mock.get_available_programs = AsyncMock(side_effect=exception)
|
||||
mock.put_command = AsyncMock(side_effect=exception)
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture(name="appliance_ha_id")
|
||||
def mock_appliance_ha_id(request: pytest.FixtureRequest) -> str:
|
||||
"""Fixture to mock Appliance."""
|
||||
app = "Washer"
|
||||
if hasattr(request, "param") and request.param:
|
||||
app = request.param
|
||||
|
||||
mock = MagicMock(
|
||||
autospec=HomeConnectAppliance,
|
||||
**MOCK_APPLIANCES_PROPERTIES.get(app),
|
||||
)
|
||||
mock.name = app
|
||||
type(mock).status = PropertyMock(return_value={})
|
||||
mock.get.return_value = {}
|
||||
mock.get_programs_available.return_value = []
|
||||
mock.get_status.return_value = {}
|
||||
mock.get_settings.return_value = {}
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
@pytest.fixture(name="problematic_appliance")
|
||||
def mock_problematic_appliance(request: pytest.FixtureRequest) -> Mock:
|
||||
"""Fixture to mock a problematic Appliance."""
|
||||
app = "Washer"
|
||||
if hasattr(request, "param") and request.param:
|
||||
app = request.param
|
||||
|
||||
mock = Mock(
|
||||
autospec=HomeConnectAppliance,
|
||||
**MOCK_APPLIANCES_PROPERTIES.get(app),
|
||||
)
|
||||
mock.name = app
|
||||
type(mock).status = PropertyMock(return_value={})
|
||||
mock.get.side_effect = HomeConnectError
|
||||
mock.get_programs_active.side_effect = HomeConnectError
|
||||
mock.get_programs_available.side_effect = HomeConnectError
|
||||
mock.start_program.side_effect = HomeConnectError
|
||||
mock.select_program.side_effect = HomeConnectError
|
||||
mock.pause_program.side_effect = HomeConnectError
|
||||
mock.stop_program.side_effect = HomeConnectError
|
||||
mock.set_options_active_program.side_effect = HomeConnectError
|
||||
mock.set_options_selected_program.side_effect = HomeConnectError
|
||||
mock.get_status.side_effect = HomeConnectError
|
||||
mock.get_settings.side_effect = HomeConnectError
|
||||
mock.set_setting.side_effect = HomeConnectError
|
||||
mock.set_setting.side_effect = HomeConnectError
|
||||
mock.execute_command.side_effect = HomeConnectError
|
||||
|
||||
return mock
|
||||
|
||||
|
||||
def get_all_appliances():
|
||||
"""Return a list of `HomeConnectAppliance` instances for all appliances."""
|
||||
|
||||
appliances = {}
|
||||
|
||||
data = load_json_object_fixture("home_connect/appliances.json").get("data")
|
||||
programs_active = load_json_object_fixture("home_connect/programs-active.json")
|
||||
programs_available = load_json_object_fixture(
|
||||
"home_connect/programs-available.json"
|
||||
)
|
||||
|
||||
def listen_callback(mock, callback):
|
||||
callback["callback"](mock)
|
||||
|
||||
for home_appliance in data["homeappliances"]:
|
||||
api_status = load_json_object_fixture("home_connect/status.json")
|
||||
api_settings = load_json_object_fixture("home_connect/settings.json")
|
||||
|
||||
ha_id = home_appliance["haId"]
|
||||
ha_type = home_appliance["type"]
|
||||
|
||||
appliance = MagicMock(spec=HomeConnectAppliance, **home_appliance)
|
||||
appliance.name = home_appliance["name"]
|
||||
appliance.listen_events.side_effect = (
|
||||
lambda app=appliance, **x: listen_callback(app, x)
|
||||
)
|
||||
appliance.get_programs_active.return_value = programs_active.get(
|
||||
ha_type, {}
|
||||
).get("data", {})
|
||||
appliance.get_programs_available.return_value = [
|
||||
program["key"]
|
||||
for program in programs_available.get(ha_type, {})
|
||||
.get("data", {})
|
||||
.get("programs", [])
|
||||
]
|
||||
appliance.get_status.return_value = HomeConnectAppliance.json2dict(
|
||||
api_status.get("data", {}).get("status", [])
|
||||
)
|
||||
appliance.get_settings.return_value = HomeConnectAppliance.json2dict(
|
||||
api_settings.get(ha_type, {}).get("data", {}).get("settings", [])
|
||||
)
|
||||
setattr(appliance, "status", {})
|
||||
appliance.status.update(appliance.get_status.return_value)
|
||||
appliance.status.update(appliance.get_settings.return_value)
|
||||
appliance.set_setting.side_effect = (
|
||||
lambda x, y, appliance=appliance: appliance.status.update({x: {"value": y}})
|
||||
)
|
||||
appliance.start_program.side_effect = (
|
||||
lambda x, appliance=appliance: appliance.status.update(
|
||||
{"BSH.Common.Root.ActiveProgram": {"value": x}}
|
||||
)
|
||||
)
|
||||
appliance.stop_program.side_effect = (
|
||||
lambda appliance=appliance: appliance.status.update(
|
||||
{"BSH.Common.Root.ActiveProgram": {}}
|
||||
)
|
||||
)
|
||||
|
||||
appliances[ha_id] = appliance
|
||||
|
||||
return list(appliances.values())
|
||||
for appliance in MOCK_APPLIANCES.homeappliances:
|
||||
if appliance.type == app:
|
||||
return appliance.ha_id
|
||||
raise ValueError(f"Appliance {app} not found")
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"Dishwasher": {
|
||||
"data": {
|
||||
"settings": [
|
||||
{
|
||||
"key": "BSH.Common.Setting.ChildLock",
|
||||
"value": false,
|
||||
"type": "Boolean"
|
||||
},
|
||||
{
|
||||
"key": "BSH.Common.Setting.AmbientLightEnabled",
|
||||
"value": true,
|
||||
@@ -26,7 +31,13 @@
|
||||
{
|
||||
"key": "BSH.Common.Setting.PowerState",
|
||||
"value": "BSH.Common.EnumType.PowerState.On",
|
||||
"type": "BSH.Common.EnumType.PowerState"
|
||||
"type": "BSH.Common.EnumType.PowerState",
|
||||
"constraints": {
|
||||
"allowedvalues": [
|
||||
"BSH.Common.EnumType.PowerState.On",
|
||||
"BSH.Common.EnumType.PowerState.Off"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "BSH.Common.Setting.ChildLock",
|
||||
@@ -92,6 +103,11 @@
|
||||
"key": "BSH.Common.Setting.PowerState",
|
||||
"value": "BSH.Common.EnumType.PowerState.On",
|
||||
"type": "BSH.Common.EnumType.PowerState"
|
||||
},
|
||||
{
|
||||
"key": "BSH.Common.Setting.AlarmClock",
|
||||
"value": 0,
|
||||
"type": "Integer"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -154,6 +170,12 @@
|
||||
"max": 100,
|
||||
"access": "readWrite"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator",
|
||||
"value": 8,
|
||||
"unit": "°C",
|
||||
"type": "Double"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,255 +2,209 @@
|
||||
# name: test_async_get_config_entry_diagnostics
|
||||
dict({
|
||||
'BOSCH-000000000-000000000000': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/00',
|
||||
'ha_id': 'BOSCH-000000000-000000000000',
|
||||
'name': 'DNE',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'DNE',
|
||||
'vib': 'HCS000000',
|
||||
}),
|
||||
'BOSCH-HCS000000-D00000000001': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/01',
|
||||
'ha_id': 'BOSCH-HCS000000-D00000000001',
|
||||
'name': 'WasherDryer',
|
||||
'programs': list([
|
||||
'LaundryCare.WasherDryer.Program.Mix',
|
||||
'LaundryCare.Washer.Option.Temperature',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'WasherDryer',
|
||||
'vib': 'HCS000001',
|
||||
}),
|
||||
'BOSCH-HCS000000-D00000000002': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/02',
|
||||
'ha_id': 'BOSCH-HCS000000-D00000000002',
|
||||
'name': 'Refrigerator',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Refrigerator',
|
||||
'vib': 'HCS000002',
|
||||
}),
|
||||
'BOSCH-HCS000000-D00000000003': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/03',
|
||||
'ha_id': 'BOSCH-HCS000000-D00000000003',
|
||||
'name': 'Freezer',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Freezer',
|
||||
'vib': 'HCS000003',
|
||||
}),
|
||||
'BOSCH-HCS000000-D00000000004': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/04',
|
||||
'ha_id': 'BOSCH-HCS000000-D00000000004',
|
||||
'name': 'Hood',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Setting.AmbientLightBrightness': dict({
|
||||
'type': 'Double',
|
||||
'unit': '%',
|
||||
'value': 70,
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightColor': dict({
|
||||
'type': 'BSH.Common.EnumType.AmbientLightColor',
|
||||
'value': 'BSH.Common.EnumType.AmbientLightColor.Color43',
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightCustomColor': dict({
|
||||
'type': 'String',
|
||||
'value': '#4a88f8',
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightEnabled': dict({
|
||||
'type': 'Boolean',
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Setting.ColorTemperature': dict({
|
||||
'type': 'BSH.Common.EnumType.ColorTemperature',
|
||||
'value': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral',
|
||||
}),
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Cooking.Common.Setting.Lighting': dict({
|
||||
'type': 'Boolean',
|
||||
'value': True,
|
||||
}),
|
||||
'Cooking.Common.Setting.LightingBrightness': dict({
|
||||
'type': 'Double',
|
||||
'unit': '%',
|
||||
'value': 70,
|
||||
}),
|
||||
'Cooking.Hood.Setting.ColorTemperaturePercent': dict({
|
||||
'type': 'Double',
|
||||
'unit': '%',
|
||||
'value': 70,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
'BSH.Common.Setting.AmbientLightBrightness': 70,
|
||||
'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43',
|
||||
'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8',
|
||||
'BSH.Common.Setting.AmbientLightEnabled': True,
|
||||
'Cooking.Common.Setting.Lighting': True,
|
||||
'Cooking.Common.Setting.LightingBrightness': 70,
|
||||
'Cooking.Hood.Setting.ColorTemperaturePercent': 70,
|
||||
'unknown': 'Cooking.Hood.EnumType.ColorTemperature.warmToNeutral',
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Hood',
|
||||
'vib': 'HCS000004',
|
||||
}),
|
||||
'BOSCH-HCS000000-D00000000005': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/05',
|
||||
'ha_id': 'BOSCH-HCS000000-D00000000005',
|
||||
'name': 'Hob',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Hob',
|
||||
'vib': 'HCS000005',
|
||||
}),
|
||||
'BOSCH-HCS000000-D00000000006': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS000000/06',
|
||||
'ha_id': 'BOSCH-HCS000000-D00000000006',
|
||||
'name': 'CookProcessor',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'CookProcessor',
|
||||
'vib': 'HCS000006',
|
||||
}),
|
||||
'BOSCH-HCS01OVN1-43E0065FE245': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS01OVN1/03',
|
||||
'ha_id': 'BOSCH-HCS01OVN1-43E0065FE245',
|
||||
'name': 'Oven',
|
||||
'programs': list([
|
||||
'Cooking.Oven.Program.HeatingMode.HotAir',
|
||||
'Cooking.Oven.Program.HeatingMode.TopBottomHeating',
|
||||
'Cooking.Oven.Program.HeatingMode.PizzaSetting',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Root.ActiveProgram': dict({
|
||||
'value': 'Cooking.Oven.Program.HeatingMode.HotAir',
|
||||
}),
|
||||
'BSH.Common.Setting.PowerState': dict({
|
||||
'type': 'BSH.Common.EnumType.PowerState',
|
||||
'value': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
'BSH.Common.Setting.AlarmClock': 0,
|
||||
'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Oven',
|
||||
'vib': 'HCS01OVN1',
|
||||
}),
|
||||
'BOSCH-HCS04DYR1-831694AE3C5A': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS04DYR1/03',
|
||||
'ha_id': 'BOSCH-HCS04DYR1-831694AE3C5A',
|
||||
'name': 'Dryer',
|
||||
'programs': list([
|
||||
'LaundryCare.Dryer.Program.Cotton',
|
||||
'LaundryCare.Dryer.Program.Synthetic',
|
||||
'LaundryCare.Dryer.Program.Mix',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Dryer',
|
||||
'vib': 'HCS04DYR1',
|
||||
}),
|
||||
'BOSCH-HCS06COM1-D70390681C2C': dict({
|
||||
'brand': 'BOSCH',
|
||||
'connected': True,
|
||||
'e_number': 'HCS06COM1/03',
|
||||
'ha_id': 'BOSCH-HCS06COM1-D70390681C2C',
|
||||
'name': 'CoffeeMaker',
|
||||
'programs': list([
|
||||
'ConsumerProducts.CoffeeMaker.Program.Beverage.Espresso',
|
||||
'ConsumerProducts.CoffeeMaker.Program.Beverage.EspressoMacchiato',
|
||||
@@ -259,26 +213,24 @@
|
||||
'ConsumerProducts.CoffeeMaker.Program.Beverage.LatteMacchiato',
|
||||
'ConsumerProducts.CoffeeMaker.Program.Beverage.CaffeLatte',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'CoffeeMaker',
|
||||
'vib': 'HCS06COM1',
|
||||
}),
|
||||
'SIEMENS-HCS02DWH1-6BE58C26DCC1': dict({
|
||||
'brand': 'SIEMENS',
|
||||
'connected': True,
|
||||
'e_number': 'HCS02DWH1/03',
|
||||
'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1',
|
||||
'name': 'Dishwasher',
|
||||
'programs': list([
|
||||
'Dishcare.Dishwasher.Program.Auto1',
|
||||
'Dishcare.Dishwasher.Program.Auto2',
|
||||
@@ -286,51 +238,30 @@
|
||||
'Dishcare.Dishwasher.Program.Eco50',
|
||||
'Dishcare.Dishwasher.Program.Quick45',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Setting.AmbientLightBrightness': dict({
|
||||
'type': 'Double',
|
||||
'unit': '%',
|
||||
'value': 70,
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightColor': dict({
|
||||
'type': 'BSH.Common.EnumType.AmbientLightColor',
|
||||
'value': 'BSH.Common.EnumType.AmbientLightColor.Color43',
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightCustomColor': dict({
|
||||
'type': 'String',
|
||||
'value': '#4a88f8',
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightEnabled': dict({
|
||||
'type': 'Boolean',
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Setting.ChildLock': dict({
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
}),
|
||||
'BSH.Common.Setting.PowerState': dict({
|
||||
'type': 'BSH.Common.EnumType.PowerState',
|
||||
'value': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
'BSH.Common.Setting.AmbientLightBrightness': 70,
|
||||
'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43',
|
||||
'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8',
|
||||
'BSH.Common.Setting.AmbientLightEnabled': True,
|
||||
'BSH.Common.Setting.ChildLock': False,
|
||||
'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Dishwasher',
|
||||
'vib': 'HCS02DWH1',
|
||||
}),
|
||||
'SIEMENS-HCS03WCH1-7BC6383CF794': dict({
|
||||
'brand': 'SIEMENS',
|
||||
'connected': True,
|
||||
'e_number': 'HCS03WCH1/03',
|
||||
'ha_id': 'SIEMENS-HCS03WCH1-7BC6383CF794',
|
||||
'name': 'Washer',
|
||||
'programs': list([
|
||||
'LaundryCare.Washer.Program.Cotton',
|
||||
'LaundryCare.Washer.Program.EasyCare',
|
||||
@@ -338,97 +269,55 @@
|
||||
'LaundryCare.Washer.Program.DelicatesSilk',
|
||||
'LaundryCare.Washer.Program.Wool',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Root.ActiveProgram': dict({
|
||||
'value': 'BSH.Common.Root.ActiveProgram',
|
||||
}),
|
||||
'BSH.Common.Setting.ChildLock': dict({
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
}),
|
||||
'BSH.Common.Setting.PowerState': dict({
|
||||
'type': 'BSH.Common.EnumType.PowerState',
|
||||
'value': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
'BSH.Common.Setting.ChildLock': False,
|
||||
'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Washer',
|
||||
'vib': 'HCS03WCH1',
|
||||
}),
|
||||
'SIEMENS-HCS05FRF1-304F4F9E541D': dict({
|
||||
'brand': 'SIEMENS',
|
||||
'connected': True,
|
||||
'e_number': 'HCS05FRF1/03',
|
||||
'ha_id': 'SIEMENS-HCS05FRF1-304F4F9E541D',
|
||||
'name': 'FridgeFreezer',
|
||||
'programs': list([
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Setting.Dispenser.Enabled': dict({
|
||||
'constraints': dict({
|
||||
'access': 'readWrite',
|
||||
}),
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
}),
|
||||
'Refrigeration.Common.Setting.Light.External.Brightness': dict({
|
||||
'constraints': dict({
|
||||
'access': 'readWrite',
|
||||
'max': 100,
|
||||
'min': 0,
|
||||
}),
|
||||
'type': 'Double',
|
||||
'unit': '%',
|
||||
'value': 70,
|
||||
}),
|
||||
'Refrigeration.Common.Setting.Light.External.Power': dict({
|
||||
'type': 'Boolean',
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': dict({
|
||||
'constraints': dict({
|
||||
'access': 'readWrite',
|
||||
}),
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
}),
|
||||
'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': dict({
|
||||
'constraints': dict({
|
||||
'access': 'readWrite',
|
||||
}),
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
}),
|
||||
'settings': dict({
|
||||
'Refrigeration.Common.Setting.Dispenser.Enabled': False,
|
||||
'Refrigeration.Common.Setting.Light.External.Brightness': 70,
|
||||
'Refrigeration.Common.Setting.Light.External.Power': True,
|
||||
'Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator': 8,
|
||||
'Refrigeration.FridgeFreezer.Setting.SuperModeFreezer': False,
|
||||
'Refrigeration.FridgeFreezer.Setting.SuperModeRefrigerator': False,
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'FridgeFreezer',
|
||||
'vib': 'HCS05FRF1',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
# name: test_async_get_device_diagnostics
|
||||
dict({
|
||||
'brand': 'SIEMENS',
|
||||
'connected': True,
|
||||
'e_number': 'HCS02DWH1/03',
|
||||
'ha_id': 'SIEMENS-HCS02DWH1-6BE58C26DCC1',
|
||||
'name': 'Dishwasher',
|
||||
'programs': list([
|
||||
'Dishcare.Dishwasher.Program.Auto1',
|
||||
'Dishcare.Dishwasher.Program.Auto2',
|
||||
@@ -436,47 +325,22 @@
|
||||
'Dishcare.Dishwasher.Program.Eco50',
|
||||
'Dishcare.Dishwasher.Program.Quick45',
|
||||
]),
|
||||
'status': dict({
|
||||
'BSH.Common.Setting.AmbientLightBrightness': dict({
|
||||
'type': 'Double',
|
||||
'unit': '%',
|
||||
'value': 70,
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightColor': dict({
|
||||
'type': 'BSH.Common.EnumType.AmbientLightColor',
|
||||
'value': 'BSH.Common.EnumType.AmbientLightColor.Color43',
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightCustomColor': dict({
|
||||
'type': 'String',
|
||||
'value': '#4a88f8',
|
||||
}),
|
||||
'BSH.Common.Setting.AmbientLightEnabled': dict({
|
||||
'type': 'Boolean',
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Setting.ChildLock': dict({
|
||||
'type': 'Boolean',
|
||||
'value': False,
|
||||
}),
|
||||
'BSH.Common.Setting.PowerState': dict({
|
||||
'type': 'BSH.Common.EnumType.PowerState',
|
||||
'value': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'BSH.Common.Status.DoorState': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
}),
|
||||
'BSH.Common.Status.OperationState': dict({
|
||||
'value': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlActive': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': dict({
|
||||
'value': True,
|
||||
}),
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': dict({
|
||||
'value': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'settings': dict({
|
||||
'BSH.Common.Setting.AmbientLightBrightness': 70,
|
||||
'BSH.Common.Setting.AmbientLightColor': 'BSH.Common.EnumType.AmbientLightColor.Color43',
|
||||
'BSH.Common.Setting.AmbientLightCustomColor': '#4a88f8',
|
||||
'BSH.Common.Setting.AmbientLightEnabled': True,
|
||||
'BSH.Common.Setting.ChildLock': False,
|
||||
'BSH.Common.Setting.PowerState': 'BSH.Common.EnumType.PowerState.On',
|
||||
}),
|
||||
'status': dict({
|
||||
'BSH.Common.Status.DoorState': 'BSH.Common.EnumType.DoorState.Closed',
|
||||
'BSH.Common.Status.OperationState': 'BSH.Common.EnumType.OperationState.Ready',
|
||||
'BSH.Common.Status.RemoteControlActive': True,
|
||||
'BSH.Common.Status.RemoteControlStartAllowed': True,
|
||||
'Refrigeration.Common.Status.Door.Refrigerator': 'BSH.Common.EnumType.DoorState.Open',
|
||||
}),
|
||||
'type': 'Dishwasher',
|
||||
'vib': 'HCS02DWH1',
|
||||
})
|
||||
# ---
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
"""Tests for home_connect binary_sensor entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeconnect.api import HomeConnectAPI
|
||||
from aiohomeconnect.model import ArrayOfEvents, Event, EventKey, EventMessage, EventType
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation, script
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_DOOR_STATE,
|
||||
BSH_DOOR_STATE_CLOSED,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
DOMAIN,
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN,
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
)
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.entity_component import async_update_entity
|
||||
import homeassistant.helpers.issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -35,123 +32,166 @@ def platforms() -> list[str]:
|
||||
return [Platform.BINARY_SENSOR]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_binary_sensors(
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test binary sensor entities."""
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "expected"),
|
||||
("value", "expected"),
|
||||
[
|
||||
(BSH_DOOR_STATE_CLOSED, "off"),
|
||||
(BSH_DOOR_STATE_LOCKED, "off"),
|
||||
(BSH_DOOR_STATE_OPEN, "on"),
|
||||
("", "unavailable"),
|
||||
("", STATE_UNKNOWN),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_binary_sensors_door_states(
|
||||
appliance_ha_id: str,
|
||||
expected: str,
|
||||
state: str,
|
||||
value: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Tests for Appliance door states."""
|
||||
entity_id = "binary_sensor.washer_door"
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
appliance.status.update({BSH_DOOR_STATE: {"value": state}})
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
await async_update_entity(hass, entity_id)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.STATUS,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=value,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status_key", "event_value_update", "expected", "appliance"),
|
||||
("entity_id", "event_key", "event_value_update", "expected", "appliance_ha_id"),
|
||||
[
|
||||
(
|
||||
"binary_sensor.washer_remote_control",
|
||||
EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE,
|
||||
False,
|
||||
STATE_OFF,
|
||||
"Washer",
|
||||
),
|
||||
(
|
||||
"binary_sensor.washer_remote_control",
|
||||
EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE,
|
||||
True,
|
||||
STATE_ON,
|
||||
"Washer",
|
||||
),
|
||||
(
|
||||
"binary_sensor.washer_remote_control",
|
||||
EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE,
|
||||
"",
|
||||
STATE_UNKNOWN,
|
||||
"Washer",
|
||||
),
|
||||
(
|
||||
"binary_sensor.fridgefreezer_refrigerator_door",
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR,
|
||||
REFRIGERATION_STATUS_DOOR_CLOSED,
|
||||
STATE_OFF,
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"binary_sensor.fridgefreezer_refrigerator_door",
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR,
|
||||
REFRIGERATION_STATUS_DOOR_OPEN,
|
||||
STATE_ON,
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"binary_sensor.fridgefreezer_refrigerator_door",
|
||||
REFRIGERATION_STATUS_DOOR_REFRIGERATOR,
|
||||
EventKey.REFRIGERATION_COMMON_STATUS_DOOR_REFRIGERATOR,
|
||||
"",
|
||||
STATE_UNAVAILABLE,
|
||||
STATE_UNKNOWN,
|
||||
"FridgeFreezer",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_bianry_sensors_fridge_door_states(
|
||||
async def test_binary_sensors_functionality(
|
||||
entity_id: str,
|
||||
status_key: str,
|
||||
event_key: EventKey,
|
||||
event_value_update: str,
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
expected: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Tests for Home Connect Fridge appliance door states."""
|
||||
appliance.status.update(
|
||||
HomeConnectAPI.json2dict(
|
||||
load_json_object_fixture("home_connect/status.json")["data"]["status"]
|
||||
)
|
||||
)
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
appliance.status.update({status_key: {"value": event_value_update}})
|
||||
await async_update_entity(hass, entity_id)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.STATUS,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=event_value_update,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, expected)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_create_issue(
|
||||
hass: HomeAssistant,
|
||||
appliance: Mock,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test we create an issue when an automation or script is using a deprecated entity."""
|
||||
entity_id = "binary_sensor.washer_door"
|
||||
get_appliances.return_value = [appliance]
|
||||
issue_id = f"deprecated_binary_common_door_sensor_{entity_id}"
|
||||
|
||||
assert await async_setup_component(
|
||||
@@ -189,8 +229,7 @@ async def test_create_issue(
|
||||
)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
appliance.status.update({BSH_DOOR_STATE: {"value": BSH_DOOR_STATE_OPEN}})
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert automations_with_entity(hass, entity_id)[0] == "automation.test"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohomeconnect.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
@@ -10,11 +11,7 @@ from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import (
|
||||
DOMAIN,
|
||||
OAUTH2_AUTHORIZE,
|
||||
OAUTH2_TOKEN,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Test for Home Connect coordinator."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfEvents,
|
||||
ArrayOfSettings,
|
||||
ArrayOfStatus,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
Status,
|
||||
StatusKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import (
|
||||
EventStreamInterruptedError,
|
||||
HomeConnectApiError,
|
||||
HomeConnectError,
|
||||
HomeConnectRequestError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
BSH_EVENT_PRESENT_STATE_PRESENT,
|
||||
BSH_POWER_OFF,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntries, ConfigEntryState
|
||||
from homeassistant.const import EVENT_STATE_REPORTED, Platform
|
||||
from homeassistant.core import (
|
||||
Event as HassEvent,
|
||||
EventStateReportedData,
|
||||
HomeAssistant,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
"""Fixture to specify platforms to test."""
|
||||
return [Platform.SENSOR, Platform.SWITCH]
|
||||
|
||||
|
||||
async def test_coordinator_update(
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that the coordinator can update."""
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_coordinator_update_failing_get_appliances(
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test that the coordinator raises ConfigEntryNotReady when it fails to get appliances."""
|
||||
client_with_exception.get_home_appliances.return_value = None
|
||||
client_with_exception.get_home_appliances.side_effect = HomeConnectError()
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_coordinator_update_failing_get_settings_status(
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test that although is not possible to get settings and status, the config entry is loaded.
|
||||
|
||||
This is for cases where some appliances are reachable and some are not in the same configuration entry.
|
||||
"""
|
||||
# Get home appliances does pass at client_with_exception.get_home_appliances mock, so no need to mock it again
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("event_type", "event_key", "event_value", "entity_id"),
|
||||
[
|
||||
(
|
||||
EventType.STATUS,
|
||||
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
"sensor.dishwasher_door",
|
||||
),
|
||||
(
|
||||
EventType.NOTIFY,
|
||||
EventKey.BSH_COMMON_SETTING_POWER_STATE,
|
||||
BSH_POWER_OFF,
|
||||
"switch.dishwasher_power",
|
||||
),
|
||||
(
|
||||
EventType.EVENT,
|
||||
EventKey.DISHCARE_DISHWASHER_EVENT_SALT_NEARLY_EMPTY,
|
||||
BSH_EVENT_PRESENT_STATE_PRESENT,
|
||||
"sensor.dishwasher_salt_nearly_empty",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_event_listener(
|
||||
event_type: EventType,
|
||||
event_key: EventKey,
|
||||
event_value: str,
|
||||
entity_id: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test that the event listener works."""
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
event_message = EventMessage(
|
||||
appliance_ha_id,
|
||||
event_type,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=event_value,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
await client.add_events([event_message])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
new_state = hass.states.get(entity_id)
|
||||
assert new_state
|
||||
assert new_state.state != state.state
|
||||
|
||||
# Following, we are gonna check that the listeners are clean up correctly
|
||||
new_entity_id = entity_id + "_new"
|
||||
listener = MagicMock()
|
||||
|
||||
@callback
|
||||
def listener_callback(event: HassEvent[EventStateReportedData]) -> None:
|
||||
listener(event.data["entity_id"])
|
||||
|
||||
@callback
|
||||
def event_filter(_: EventStateReportedData) -> bool:
|
||||
return True
|
||||
|
||||
hass.bus.async_listen(EVENT_STATE_REPORTED, listener_callback, event_filter)
|
||||
|
||||
entity_registry.async_update_entity(entity_id, new_entity_id=new_entity_id)
|
||||
await hass.async_block_till_done()
|
||||
await client.add_events([event_message])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Because the entity's id has been updated, the entity has been unloaded
|
||||
# and the listener has been removed, and the new entity adds a new listener,
|
||||
# so the only entity that should report states is the one with the new entity id
|
||||
listener.assert_called_once_with(new_entity_id)
|
||||
|
||||
|
||||
async def tests_receive_setting_and_status_for_first_time_at_events(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
) -> None:
|
||||
"""Test that the event listener is capable of receiving settings and status for the first time."""
|
||||
client.get_setting = AsyncMock(return_value=ArrayOfSettings([]))
|
||||
client.get_status = AsyncMock(return_value=ArrayOfStatus([]))
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.NOTIFY,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL,
|
||||
raw_key=EventKey.LAUNDRY_CARE_WASHER_SETTING_I_DOS_1_BASE_LEVEL.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value="some value",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.STATUS,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
raw_key=EventKey.BSH_COMMON_STATUS_DOOR_STATE.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value="some value",
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert len(config_entry._background_tasks) == 1
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_event_listener_error(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test that the configuration entry is reloaded when the event stream raises an API error."""
|
||||
client_with_exception.stream_all_events = MagicMock(
|
||||
side_effect=HomeConnectApiError("error.key", "error description")
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
ConfigEntries,
|
||||
"async_schedule_reload",
|
||||
) as mock_schedule_reload:
|
||||
await integration_setup(client_with_exception)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
client_with_exception.stream_all_events.assert_called_once()
|
||||
mock_schedule_reload.assert_called_once_with(config_entry.entry_id)
|
||||
assert not config_entry._background_tasks
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[HomeConnectRequestError(), EventStreamInterruptedError()],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"initial_state",
|
||||
"status_key",
|
||||
"status_value",
|
||||
"after_refresh_expected_state",
|
||||
"event_key",
|
||||
"event_value",
|
||||
"after_event_expected_state",
|
||||
),
|
||||
[
|
||||
(
|
||||
"sensor.washer_door",
|
||||
"closed",
|
||||
StatusKey.BSH_COMMON_DOOR_STATE,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
"locked",
|
||||
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
"open",
|
||||
),
|
||||
],
|
||||
)
|
||||
@patch(
|
||||
"homeassistant.components.home_connect.coordinator.EVENT_STREAM_RECONNECT_DELAY", 0
|
||||
)
|
||||
async def test_event_listener_resilience(
|
||||
entity_id: str,
|
||||
initial_state: str,
|
||||
status_key: StatusKey,
|
||||
status_value: Any,
|
||||
after_refresh_expected_state: str,
|
||||
event_key: EventKey,
|
||||
event_value: Any,
|
||||
after_event_expected_state: str,
|
||||
exception: HomeConnectError,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
) -> None:
|
||||
"""Test that the event listener is resilient to interruptions."""
|
||||
future = hass.loop.create_future()
|
||||
|
||||
async def stream_exception():
|
||||
yield await future
|
||||
|
||||
client.stream_all_events = MagicMock(
|
||||
side_effect=[stream_exception(), client.stream_all_events()]
|
||||
)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
await integration_setup(client)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
assert len(config_entry._background_tasks) == 1
|
||||
|
||||
assert hass.states.is_state(entity_id, initial_state)
|
||||
|
||||
client.get_status.return_value = ArrayOfStatus(
|
||||
[Status(key=status_key, raw_key=status_key.value, value=status_value)],
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
future.set_exception(exception)
|
||||
await hass.async_block_till_done()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert client.stream_all_events.call_count == 2
|
||||
assert hass.states.is_state(entity_id, after_refresh_expected_state)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.STATUS,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=event_value,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.is_state(entity_id, after_event_expected_state)
|
||||
@@ -1,11 +1,9 @@
|
||||
"""Test diagnostics for Home Connect."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
import pytest
|
||||
from syrupy import SnapshotAssertion
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.components.home_connect.diagnostics import (
|
||||
@@ -16,43 +14,37 @@ from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from .conftest import get_all_appliances
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test config entry diagnostics."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert await async_get_config_entry_diagnostics(hass, config_entry) == snapshot
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_async_get_device_diagnostics(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test device config entry diagnostics."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
@@ -61,69 +53,3 @@ async def test_async_get_device_diagnostics(
|
||||
)
|
||||
|
||||
assert await async_get_device_diagnostics(hass, config_entry, device) == snapshot
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_async_device_diagnostics_not_found(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test device config entry diagnostics."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, "Random-Device-ID")},
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
await async_get_device_diagnostics(hass, config_entry, device)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api_error", "expected_connection_status"),
|
||||
[
|
||||
(HomeConnectError(), "unknown"),
|
||||
(
|
||||
HomeConnectError(
|
||||
{
|
||||
"key": "SDK.Error.HomeAppliance.Connection.Initialization.Failed",
|
||||
}
|
||||
),
|
||||
"offline",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_async_device_diagnostics_api_error(
|
||||
api_error: HomeConnectError,
|
||||
expected_connection_status: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
appliance: Mock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Test device config entry diagnostics."""
|
||||
appliance.get_programs_available.side_effect = api_error
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
device = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, appliance.haId)},
|
||||
)
|
||||
|
||||
diagnostics = await async_get_device_diagnostics(hass, config_entry, device)
|
||||
assert diagnostics["programs"] is None
|
||||
|
||||
@@ -2,27 +2,18 @@
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from aiohomeconnect.const import OAUTH2_TOKEN
|
||||
from aiohomeconnect.model import SettingKey, StatusKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import pytest
|
||||
from requests import HTTPError
|
||||
import requests_mock
|
||||
import respx
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
|
||||
from homeassistant.components.home_connect import (
|
||||
SCAN_INTERVAL,
|
||||
bsh_key_to_translation_key,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_CHILD_LOCK_STATE,
|
||||
BSH_OPERATION_STATE,
|
||||
BSH_POWER_STATE,
|
||||
BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
COOKING_LIGHTING,
|
||||
DOMAIN,
|
||||
OAUTH2_TOKEN,
|
||||
)
|
||||
from homeassistant.components.home_connect.const import DOMAIN
|
||||
from homeassistant.components.home_connect.utils import bsh_key_to_translation_key
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
@@ -39,7 +30,6 @@ from .conftest import (
|
||||
FAKE_ACCESS_TOKEN,
|
||||
FAKE_REFRESH_TOKEN,
|
||||
SERVER_ACCESS_TOKEN,
|
||||
get_all_appliances,
|
||||
)
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [
|
||||
]
|
||||
|
||||
SERVICE_APPLIANCE_METHOD_MAPPING = {
|
||||
"set_option_active": "set_options_active_program",
|
||||
"set_option_selected": "set_options_selected_program",
|
||||
"set_option_active": "set_active_program_option",
|
||||
"set_option_selected": "set_selected_program_option",
|
||||
"change_setting": "set_setting",
|
||||
"pause_program": "execute_command",
|
||||
"resume_program": "execute_command",
|
||||
"select_program": "select_program",
|
||||
"pause_program": "put_command",
|
||||
"resume_program": "put_command",
|
||||
"select_program": "set_selected_program",
|
||||
"start_program": "start_program",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_api_setup(
|
||||
async def test_entry_setup(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup and unload."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
@@ -156,72 +144,60 @@ async def test_api_setup(
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
async def test_update_throttle(
|
||||
appliance: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
) -> None:
|
||||
"""Test to check Throttle functionality."""
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
get_appliances_call_count = get_appliances.call_count
|
||||
|
||||
# First re-load after 1 minute is not blocked.
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
freezer.tick(SCAN_INTERVAL.seconds + 0.1)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert get_appliances.call_count == get_appliances_call_count + 1
|
||||
|
||||
# Second re-load is blocked by Throttle.
|
||||
assert await hass.config_entries.async_unload(config_entry.entry_id)
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
freezer.tick(SCAN_INTERVAL.seconds - 0.1)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
assert get_appliances.call_count == get_appliances_call_count + 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_exception_handling(
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
problematic_appliance: Mock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_expiration_time", [12345])
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
@respx.mock
|
||||
async def test_token_refresh_success(
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
hass: HomeAssistant,
|
||||
platforms: list[Platform],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
requests_mock: requests_mock.Mocker,
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test where token is expired and the refresh attempt succeeds."""
|
||||
|
||||
assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN
|
||||
|
||||
requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN)
|
||||
requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}})
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json=SERVER_ACCESS_TOKEN,
|
||||
)
|
||||
assert await integration_setup()
|
||||
appliances = client.get_home_appliances.return_value
|
||||
|
||||
async def mock_get_home_appliances():
|
||||
await client._auth.async_get_access_token()
|
||||
return appliances
|
||||
|
||||
client.get_home_appliances.return_value = None
|
||||
client.get_home_appliances.side_effect = mock_get_home_appliances
|
||||
|
||||
def init_side_effect(auth) -> MagicMock:
|
||||
client._auth = auth
|
||||
return client
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
with (
|
||||
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
|
||||
patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock,
|
||||
):
|
||||
client_mock.side_effect = MagicMock(side_effect=init_side_effect)
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
# Verify token request
|
||||
@@ -240,45 +216,43 @@ async def test_token_refresh_success(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_http_error(
|
||||
async def test_client_error(
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test HTTP errors during setup integration."""
|
||||
get_appliances.side_effect = HTTPError(response=MagicMock())
|
||||
"""Test client errors during setup integration."""
|
||||
client_with_exception.get_home_appliances.return_value = None
|
||||
client_with_exception.get_home_appliances.side_effect = HomeConnectError()
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
assert get_appliances.call_count == 1
|
||||
assert not await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.SETUP_RETRY
|
||||
assert client_with_exception.get_home_appliances.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_call",
|
||||
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_services(
|
||||
service_call: list[dict[str, Any]],
|
||||
service_call: dict[str, Any],
|
||||
hass: HomeAssistant,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
) -> None:
|
||||
"""Create and test services."""
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, appliance.haId)},
|
||||
identifiers={(DOMAIN, appliance_ha_id)},
|
||||
)
|
||||
|
||||
service_name = service_call["service"]
|
||||
@@ -286,8 +260,7 @@ async def test_services(
|
||||
await hass.services.async_call(**service_call)
|
||||
await hass.async_block_till_done()
|
||||
assert (
|
||||
getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count
|
||||
== 1
|
||||
getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1
|
||||
)
|
||||
|
||||
|
||||
@@ -295,26 +268,24 @@ async def test_services(
|
||||
"service_call",
|
||||
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_services_exception(
|
||||
service_call: list[dict[str, Any]],
|
||||
service_call: dict[str, Any],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
problematic_appliance: Mock,
|
||||
client_with_exception: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Raise a HomeAssistantError when there is an API error."""
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={(DOMAIN, problematic_appliance.haId)},
|
||||
identifiers={(DOMAIN, appliance_ha_id)},
|
||||
)
|
||||
|
||||
service_call["service_data"]["device_id"] = device_entry.id
|
||||
@@ -323,25 +294,47 @@ async def test_services_exception(
|
||||
await hass.services.async_call(**service_call)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_services_appliance_not_found(
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
) -> None:
|
||||
"""Raise a ServiceValidationError when device id does not match."""
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
service_call = SERVICE_KV_CALL_PARAMS[0]
|
||||
|
||||
service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS"
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"):
|
||||
await hass.services.async_call(**service_call)
|
||||
|
||||
unrelated_config_entry = MockConfigEntry(
|
||||
domain="TEST",
|
||||
)
|
||||
unrelated_config_entry.add_to_hass(hass)
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=unrelated_config_entry.entry_id,
|
||||
identifiers={("RANDOM", "ABCD")},
|
||||
)
|
||||
service_call["service_data"]["device_id"] = device_entry.id
|
||||
|
||||
with pytest.raises(
|
||||
ServiceValidationError, match=r"Home Connect config entry.*not found"
|
||||
):
|
||||
await hass.services.async_call(**service_call)
|
||||
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
identifiers={("RANDOM", "ABCD")},
|
||||
)
|
||||
service_call["service_data"]["device_id"] = device_entry.id
|
||||
|
||||
with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"):
|
||||
await hass.services.async_call(**service_call)
|
||||
|
||||
@@ -351,7 +344,7 @@ async def test_entity_migration(
|
||||
device_registry: dr.DeviceRegistry,
|
||||
entity_registry: er.EntityRegistry,
|
||||
config_entry_v1_1: MockConfigEntry,
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
platforms: list[Platform],
|
||||
) -> None:
|
||||
"""Test entity migration."""
|
||||
@@ -360,34 +353,39 @@ async def test_entity_migration(
|
||||
|
||||
device_entry = device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry_v1_1.entry_id,
|
||||
identifiers={(DOMAIN, appliance.haId)},
|
||||
identifiers={(DOMAIN, appliance_ha_id)},
|
||||
)
|
||||
|
||||
test_entities = [
|
||||
(
|
||||
SENSOR_DOMAIN,
|
||||
"Operation State",
|
||||
BSH_OPERATION_STATE,
|
||||
StatusKey.BSH_COMMON_OPERATION_STATE,
|
||||
),
|
||||
(
|
||||
SWITCH_DOMAIN,
|
||||
"ChildLock",
|
||||
BSH_CHILD_LOCK_STATE,
|
||||
SettingKey.BSH_COMMON_CHILD_LOCK,
|
||||
),
|
||||
(
|
||||
SWITCH_DOMAIN,
|
||||
"Power",
|
||||
BSH_POWER_STATE,
|
||||
SettingKey.BSH_COMMON_POWER_STATE,
|
||||
),
|
||||
(
|
||||
BINARY_SENSOR_DOMAIN,
|
||||
"Remote Start",
|
||||
BSH_REMOTE_START_ALLOWANCE_STATE,
|
||||
StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
|
||||
),
|
||||
(
|
||||
LIGHT_DOMAIN,
|
||||
"Light",
|
||||
COOKING_LIGHTING,
|
||||
SettingKey.COOKING_COMMON_LIGHTING,
|
||||
),
|
||||
( # An already migrated entity
|
||||
SWITCH_DOMAIN,
|
||||
SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
|
||||
SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -395,7 +393,7 @@ async def test_entity_migration(
|
||||
entity_registry.async_get_or_create(
|
||||
domain,
|
||||
DOMAIN,
|
||||
f"{appliance.haId}-{old_unique_id_suffix}",
|
||||
f"{appliance_ha_id}-{old_unique_id_suffix}",
|
||||
device_id=device_entry.id,
|
||||
config_entry=config_entry_v1_1,
|
||||
)
|
||||
@@ -406,7 +404,7 @@ async def test_entity_migration(
|
||||
|
||||
for domain, _, expected_unique_id_suffix in test_entities:
|
||||
assert entity_registry.async_get_entity_id(
|
||||
domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}"
|
||||
domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}"
|
||||
)
|
||||
assert config_entry_v1_1.minor_version == 2
|
||||
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
"""Tests for home_connect light entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, call
|
||||
|
||||
from homeconnect.api import HomeConnectAppliance, HomeConnectError
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfEvents,
|
||||
ArrayOfSettings,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
GetSetting,
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS,
|
||||
BSH_AMBIENT_LIGHT_COLOR,
|
||||
BSH_AMBIENT_LIGHT_CUSTOM_COLOR,
|
||||
BSH_AMBIENT_LIGHT_ENABLED,
|
||||
COOKING_LIGHTING,
|
||||
COOKING_LIGHTING_BRIGHTNESS,
|
||||
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS,
|
||||
REFRIGERATION_EXTERNAL_LIGHT_POWER,
|
||||
BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
)
|
||||
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
@@ -23,26 +27,15 @@ from homeassistant.const import (
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
STATE_ON,
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import get_all_appliances
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_HC_APP = "Hood"
|
||||
|
||||
SETTINGS_STATUS = {
|
||||
setting.pop("key"): setting
|
||||
for setting in load_json_object_fixture("home_connect/settings.json")
|
||||
.get(TEST_HC_APP)
|
||||
.get("data")
|
||||
.get("settings")
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[str]:
|
||||
@@ -51,29 +44,31 @@ def platforms() -> list[str]:
|
||||
|
||||
|
||||
async def test_light(
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch entities."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status", "service", "service_data", "state", "appliance"),
|
||||
(
|
||||
"entity_id",
|
||||
"set_settings_args",
|
||||
"service",
|
||||
"exprected_attributes",
|
||||
"state",
|
||||
"appliance_ha_id",
|
||||
),
|
||||
[
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {
|
||||
"value": True,
|
||||
},
|
||||
SettingKey.COOKING_COMMON_LIGHTING: True,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{},
|
||||
@@ -83,58 +78,18 @@ async def test_light(
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {
|
||||
"value": True,
|
||||
},
|
||||
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
|
||||
SettingKey.COOKING_COMMON_LIGHTING: True,
|
||||
SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 80,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"brightness": 200},
|
||||
{"brightness": 199},
|
||||
STATE_ON,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {"value": False},
|
||||
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
|
||||
},
|
||||
SERVICE_TURN_OFF,
|
||||
{},
|
||||
STATE_OFF,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {
|
||||
"value": None,
|
||||
},
|
||||
COOKING_LIGHTING_BRIGHTNESS: None,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{},
|
||||
STATE_UNKNOWN,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
BSH_AMBIENT_LIGHT_ENABLED: {
|
||||
"value": True,
|
||||
},
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"brightness": 200},
|
||||
STATE_ON,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
BSH_AMBIENT_LIGHT_ENABLED: {"value": False},
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
|
||||
SettingKey.COOKING_COMMON_LIGHTING: False,
|
||||
},
|
||||
SERVICE_TURN_OFF,
|
||||
{},
|
||||
@@ -144,8 +99,28 @@ async def test_light(
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
BSH_AMBIENT_LIGHT_ENABLED: {"value": True},
|
||||
BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {},
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 80,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"brightness": 199},
|
||||
STATE_ON,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: False,
|
||||
},
|
||||
SERVICE_TURN_OFF,
|
||||
{},
|
||||
STATE_OFF,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{},
|
||||
@@ -155,15 +130,28 @@ async def test_light(
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
BSH_AMBIENT_LIGHT_ENABLED: {"value": True},
|
||||
BSH_AMBIENT_LIGHT_COLOR: {
|
||||
"value": "",
|
||||
},
|
||||
BSH_AMBIENT_LIGHT_CUSTOM_COLOR: {},
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
"rgb_color": [255, 255, 0],
|
||||
"rgb_color": (255, 255, 0),
|
||||
},
|
||||
STATE_ON,
|
||||
"Hood",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc",
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
"hs_color": (255.484, 15.196),
|
||||
"brightness": 199,
|
||||
},
|
||||
STATE_ON,
|
||||
"Hood",
|
||||
@@ -171,10 +159,7 @@ async def test_light(
|
||||
(
|
||||
"light.fridgefreezer_external_light",
|
||||
{
|
||||
REFRIGERATION_EXTERNAL_LIGHT_POWER: {
|
||||
"value": True,
|
||||
},
|
||||
REFRIGERATION_EXTERNAL_LIGHT_BRIGHTNESS: {"value": 75},
|
||||
SettingKey.REFRIGERATION_COMMON_LIGHT_EXTERNAL_POWER: True,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{},
|
||||
@@ -182,167 +167,268 @@ async def test_light(
|
||||
"FridgeFreezer",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_light_functionality(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
set_settings_args: dict[SettingKey, Any],
|
||||
service: str,
|
||||
service_data: dict,
|
||||
exprected_attributes: dict[str, Any],
|
||||
state: str,
|
||||
appliance: Mock,
|
||||
bypass_throttle: Generator[None],
|
||||
appliance_ha_id: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test light functionality."""
|
||||
appliance.status.update(
|
||||
HomeConnectAppliance.json2dict(
|
||||
load_json_object_fixture("home_connect/settings.json")
|
||||
.get(appliance.name)
|
||||
.get("data")
|
||||
.get("settings")
|
||||
)
|
||||
)
|
||||
get_appliances.return_value = [appliance]
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
appliance.status.update(status)
|
||||
service_data = exprected_attributes.copy()
|
||||
service_data["entity_id"] = entity_id
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
service,
|
||||
service_data,
|
||||
blocking=True,
|
||||
{key: value for key, value in service_data.items() if value is not None},
|
||||
)
|
||||
assert hass.states.is_state(entity_id, state)
|
||||
await hass.async_block_till_done()
|
||||
client.set_setting.assert_has_calls(
|
||||
[
|
||||
call(appliance_ha_id, setting_key=setting_key, value=value)
|
||||
for setting_key, value in set_settings_args.items()
|
||||
]
|
||||
)
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state is not None
|
||||
assert entity_state.state == state
|
||||
for key, value in exprected_attributes.items():
|
||||
assert entity_state.attributes[key] == value
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"status",
|
||||
"events",
|
||||
"appliance_ha_id",
|
||||
),
|
||||
[
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
EventKey.BSH_COMMON_SETTING_AMBIENT_LIGHT_COLOR: "BSH.Common.EnumType.AmbientLightColor.Color1",
|
||||
},
|
||||
"Hood",
|
||||
),
|
||||
],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_light_color_different_than_custom(
|
||||
entity_id: str,
|
||||
events: dict[EventKey, Any],
|
||||
appliance_ha_id: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test that light color attributes are not set if color is different than custom."""
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
"rgb_color": (255, 255, 0),
|
||||
"entity_id": entity_id,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state is not None
|
||||
assert entity_state.state == STATE_ON
|
||||
assert entity_state.attributes["rgb_color"] is not None
|
||||
assert entity_state.attributes["hs_color"] is not None
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.NOTIFY,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=value,
|
||||
)
|
||||
for event_key, value in events.items()
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state is not None
|
||||
assert entity_state.state == STATE_ON
|
||||
assert entity_state.attributes["rgb_color"] is None
|
||||
assert entity_state.attributes["hs_color"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"setting",
|
||||
"service",
|
||||
"service_data",
|
||||
"mock_attr",
|
||||
"attr_side_effect",
|
||||
"problematic_appliance",
|
||||
"exception_match",
|
||||
),
|
||||
[
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {
|
||||
"value": False,
|
||||
},
|
||||
SettingKey.COOKING_COMMON_LIGHTING: True,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{},
|
||||
"set_setting",
|
||||
[HomeConnectError, HomeConnectError],
|
||||
"Hood",
|
||||
r"Error.*turn.*on.*",
|
||||
),
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {
|
||||
"value": True,
|
||||
},
|
||||
COOKING_LIGHTING_BRIGHTNESS: {"value": 70},
|
||||
SettingKey.COOKING_COMMON_LIGHTING: True,
|
||||
SettingKey.COOKING_COMMON_LIGHTING_BRIGHTNESS: 70,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"brightness": 200},
|
||||
"set_setting",
|
||||
[HomeConnectError, HomeConnectError],
|
||||
"Hood",
|
||||
r"Error.*turn.*on.*",
|
||||
),
|
||||
(
|
||||
"light.hood_functional_light",
|
||||
{
|
||||
COOKING_LIGHTING: {"value": False},
|
||||
SettingKey.COOKING_COMMON_LIGHTING: False,
|
||||
},
|
||||
SERVICE_TURN_OFF,
|
||||
{},
|
||||
"set_setting",
|
||||
[HomeConnectError, HomeConnectError],
|
||||
"Hood",
|
||||
r"Error.*turn.*off.*",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
BSH_AMBIENT_LIGHT_ENABLED: {
|
||||
"value": True,
|
||||
},
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{},
|
||||
"set_setting",
|
||||
[HomeConnectError, HomeConnectError],
|
||||
"Hood",
|
||||
r"Error.*turn.*on.*",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
BSH_AMBIENT_LIGHT_ENABLED: {
|
||||
"value": True,
|
||||
},
|
||||
BSH_AMBIENT_LIGHT_BRIGHTNESS: {"value": 70},
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"brightness": 200},
|
||||
"set_setting",
|
||||
[HomeConnectError, None, HomeConnectError],
|
||||
"Hood",
|
||||
r"Error.*set.*brightness.*",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: 70,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"rgb_color": (255, 255, 0)},
|
||||
[HomeConnectError, None, HomeConnectError],
|
||||
r"Error.*select.*custom color.*",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_BRIGHTNESS: 70,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#ffff00",
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{"rgb_color": (255, 255, 0)},
|
||||
[HomeConnectError, None, None, HomeConnectError],
|
||||
r"Error.*set.*color.*",
|
||||
),
|
||||
(
|
||||
"light.hood_ambient_light",
|
||||
{
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_ENABLED: True,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR: BSH_AMBIENT_LIGHT_COLOR_CUSTOM_COLOR,
|
||||
SettingKey.BSH_COMMON_AMBIENT_LIGHT_CUSTOM_COLOR: "#b5adcc",
|
||||
},
|
||||
SERVICE_TURN_ON,
|
||||
{
|
||||
"hs_color": (255.484, 15.196),
|
||||
"brightness": 199,
|
||||
},
|
||||
[HomeConnectError, None, None, HomeConnectError],
|
||||
r"Error.*set.*color.*",
|
||||
),
|
||||
],
|
||||
indirect=["problematic_appliance"],
|
||||
)
|
||||
async def test_switch_exception_handling(
|
||||
async def test_light_exception_handling(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
setting: dict[SettingKey, dict[str, Any]],
|
||||
service: str,
|
||||
service_data: dict,
|
||||
mock_attr: str,
|
||||
attr_side_effect: list,
|
||||
problematic_appliance: Mock,
|
||||
attr_side_effect: list[type[HomeConnectError] | None],
|
||||
exception_match: str,
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test light exception handling."""
|
||||
problematic_appliance.status.update(SETTINGS_STATUS)
|
||||
problematic_appliance.set_setting.side_effect = attr_side_effect
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
|
||||
client_with_exception.get_settings.side_effect = None
|
||||
client_with_exception.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value=value,
|
||||
)
|
||||
for setting_key, value in setting.items()
|
||||
]
|
||||
)
|
||||
client_with_exception.set_setting.side_effect = [
|
||||
exception() if exception else None for exception in attr_side_effect
|
||||
]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
# Assert that an exception is called.
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
await client_with_exception.set_setting()
|
||||
|
||||
problematic_appliance.status.update(status)
|
||||
service_data["entity_id"] = entity_id
|
||||
with pytest.raises(HomeAssistantError, match=exception_match):
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, service, service_data, blocking=True
|
||||
)
|
||||
assert getattr(problematic_appliance, mock_attr).call_count == len(attr_side_effect)
|
||||
assert client_with_exception.set_setting.call_count == len(attr_side_effect)
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
"""Tests for home_connect number entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from collections.abc import Awaitable, Callable
|
||||
import random
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.setting import SettingConstraints
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.home_connect.const import (
|
||||
ATTR_CONSTRAINTS,
|
||||
ATTR_STEPSIZE,
|
||||
ATTR_UNIT,
|
||||
ATTR_VALUE,
|
||||
)
|
||||
from homeassistant.components.number import (
|
||||
ATTR_MAX,
|
||||
ATTR_MIN,
|
||||
ATTR_VALUE as SERVICE_ATTR_VALUE,
|
||||
DEFAULT_MAX_VALUE,
|
||||
DEFAULT_MIN_VALUE,
|
||||
DOMAIN as NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
@@ -26,8 +21,6 @@ from homeassistant.const import ATTR_ENTITY_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import get_all_appliances
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@@ -38,25 +31,24 @@ def platforms() -> list[str]:
|
||||
|
||||
|
||||
async def test_number(
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test number entity."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Refrigerator"], indirect=True)
|
||||
@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"setting_key",
|
||||
"type",
|
||||
"expected_state",
|
||||
"min_value",
|
||||
"max_value",
|
||||
"step_size",
|
||||
@@ -64,102 +56,132 @@ async def test_number(
|
||||
),
|
||||
[
|
||||
(
|
||||
f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature",
|
||||
"Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator",
|
||||
f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
|
||||
SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
|
||||
"Double",
|
||||
8,
|
||||
7,
|
||||
15,
|
||||
0.1,
|
||||
"°C",
|
||||
),
|
||||
(
|
||||
f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
|
||||
SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
|
||||
"Double",
|
||||
8,
|
||||
7,
|
||||
15,
|
||||
5,
|
||||
"°C",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_number_entity_functionality(
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
entity_id: str,
|
||||
setting_key: str,
|
||||
bypass_throttle: Generator[None],
|
||||
setting_key: SettingKey,
|
||||
type: str,
|
||||
expected_state: int,
|
||||
min_value: int,
|
||||
max_value: int,
|
||||
step_size: float,
|
||||
unit_of_measurement: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test number entity functionality."""
|
||||
appliance.get.side_effect = [
|
||||
{
|
||||
ATTR_CONSTRAINTS: {
|
||||
ATTR_MIN: min_value,
|
||||
ATTR_MAX: max_value,
|
||||
ATTR_STEPSIZE: step_size,
|
||||
},
|
||||
ATTR_UNIT: unit_of_measurement,
|
||||
}
|
||||
]
|
||||
get_appliances.return_value = [appliance]
|
||||
current_value = min_value
|
||||
appliance.status.update({setting_key: {ATTR_VALUE: current_value}})
|
||||
client.get_setting.side_effect = None
|
||||
client.get_setting = AsyncMock(
|
||||
return_value=GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value="", # This should not change the value
|
||||
unit=unit_of_measurement,
|
||||
type=type,
|
||||
constraints=SettingConstraints(
|
||||
min=min_value,
|
||||
max=max_value,
|
||||
step_size=step_size if isinstance(step_size, int) else None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert hass.states.is_state(entity_id, str(current_value))
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.attributes["min"] == min_value
|
||||
assert state.attributes["max"] == max_value
|
||||
assert state.attributes["step"] == step_size
|
||||
assert state.attributes["unit_of_measurement"] == unit_of_measurement
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state
|
||||
assert entity_state.state == str(expected_state)
|
||||
attributes = entity_state.attributes
|
||||
assert attributes["min"] == min_value
|
||||
assert attributes["max"] == max_value
|
||||
assert attributes["step"] == step_size
|
||||
assert attributes["unit_of_measurement"] == unit_of_measurement
|
||||
|
||||
new_value = random.randint(min_value + 1, max_value)
|
||||
value = random.choice(
|
||||
[num for num in range(min_value, max_value + 1) if num != expected_state]
|
||||
)
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
SERVICE_ATTR_VALUE: new_value,
|
||||
SERVICE_ATTR_VALUE: value,
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
appliance.set_setting.assert_called_once_with(setting_key, new_value)
|
||||
await hass.async_block_till_done()
|
||||
client.set_setting.assert_awaited_once_with(
|
||||
appliance_ha_id, setting_key=setting_key, value=value
|
||||
)
|
||||
assert hass.states.is_state(entity_id, str(float(value)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problematic_appliance", ["Refrigerator"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "setting_key", "mock_attr"),
|
||||
[
|
||||
(
|
||||
f"{NUMBER_DOMAIN.lower()}.refrigerator_refrigerator_temperature",
|
||||
"Refrigeration.FridgeFreezer.Setting.SetpointTemperatureRefrigerator",
|
||||
f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
|
||||
SettingKey.REFRIGERATION_FRIDGE_FREEZER_SETPOINT_TEMPERATURE_REFRIGERATOR,
|
||||
"set_setting",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_number_entity_error(
|
||||
problematic_appliance: Mock,
|
||||
entity_id: str,
|
||||
setting_key: str,
|
||||
setting_key: SettingKey,
|
||||
mock_attr: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test number entity error."""
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
|
||||
client_with_exception.get_settings.side_effect = None
|
||||
client_with_exception.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value=DEFAULT_MIN_VALUE,
|
||||
constraints=SettingConstraints(
|
||||
min=int(DEFAULT_MIN_VALUE),
|
||||
max=int(DEFAULT_MAX_VALUE),
|
||||
step_size=1,
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
problematic_appliance.status.update({setting_key: {}})
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
await getattr(client_with_exception, mock_attr)()
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*"
|
||||
@@ -173,4 +195,4 @@ async def test_number_entity_error(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert getattr(problematic_appliance, mock_attr).call_count == 2
|
||||
assert getattr(client_with_exception, mock_attr).call_count == 2
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
"""Tests for home_connect select entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfAvailablePrograms,
|
||||
ArrayOfEvents,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
ProgramKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import EnumerateAvailableProgram
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_ACTIVE_PROGRAM,
|
||||
BSH_SELECTED_PROGRAM,
|
||||
)
|
||||
from homeassistant.components.select import (
|
||||
ATTR_OPTION,
|
||||
ATTR_OPTIONS,
|
||||
DOMAIN as SELECT_DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, Platform
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
SERVICE_SELECT_OPTION,
|
||||
STATE_UNKNOWN,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import get_all_appliances
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
SETTINGS_STATUS = {
|
||||
setting.pop("key"): setting
|
||||
for setting in load_json_object_fixture("home_connect/settings.json")
|
||||
.get("Washer")
|
||||
.get("data")
|
||||
.get("settings")
|
||||
}
|
||||
|
||||
PROGRAM = "Dishcare.Dishwasher.Program.Eco50"
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -43,119 +42,148 @@ def platforms() -> list[str]:
|
||||
|
||||
|
||||
async def test_select(
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test select entity."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_filter_unknown_programs(
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: Mock,
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test select that programs that are not part of the official Home Connect API specification are filtered out.
|
||||
|
||||
We use two programs to ensure that programs are iterated over a copy of the list,
|
||||
and it does not raise problems when removing an element from the original list.
|
||||
"""
|
||||
appliance.status.update(SETTINGS_STATUS)
|
||||
appliance.get_programs_available.return_value = [
|
||||
PROGRAM,
|
||||
"NonOfficialProgram",
|
||||
"AntotherNonOfficialProgram",
|
||||
]
|
||||
get_appliances.return_value = [appliance]
|
||||
"""Test select that only known programs are shown."""
|
||||
client.get_available_programs.side_effect = None
|
||||
client.get_available_programs.return_value = ArrayOfAvailablePrograms(
|
||||
[
|
||||
EnumerateAvailableProgram(
|
||||
key=ProgramKey.DISHCARE_DISHWASHER_ECO_50,
|
||||
raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value,
|
||||
),
|
||||
EnumerateAvailableProgram(
|
||||
key=ProgramKey.UNKNOWN,
|
||||
raw_key="an unknown program",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity = entity_registry.async_get("select.washer_selected_program")
|
||||
entity = entity_registry.async_get("select.dishwasher_selected_program")
|
||||
assert entity
|
||||
assert entity.capabilities.get(ATTR_OPTIONS) == [
|
||||
"dishcare_dishwasher_program_eco_50"
|
||||
]
|
||||
assert entity.capabilities
|
||||
assert entity.capabilities[ATTR_OPTIONS] == ["dishcare_dishwasher_program_eco_50"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status", "program_to_set"),
|
||||
(
|
||||
"appliance_ha_id",
|
||||
"entity_id",
|
||||
"mock_method",
|
||||
"program_key",
|
||||
"program_to_set",
|
||||
"event_key",
|
||||
),
|
||||
[
|
||||
(
|
||||
"select.washer_selected_program",
|
||||
{BSH_SELECTED_PROGRAM: {"value": PROGRAM}},
|
||||
"Dishwasher",
|
||||
"select.dishwasher_selected_program",
|
||||
"set_selected_program",
|
||||
ProgramKey.DISHCARE_DISHWASHER_ECO_50,
|
||||
"dishcare_dishwasher_program_eco_50",
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
),
|
||||
(
|
||||
"select.washer_active_program",
|
||||
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
|
||||
"Dishwasher",
|
||||
"select.dishwasher_active_program",
|
||||
"start_program",
|
||||
ProgramKey.DISHCARE_DISHWASHER_ECO_50,
|
||||
"dishcare_dishwasher_program_eco_50",
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
),
|
||||
],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_select_functionality(
|
||||
async def test_select_program_functionality(
|
||||
appliance_ha_id: str,
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
mock_method: str,
|
||||
program_key: ProgramKey,
|
||||
program_to_set: str,
|
||||
bypass_throttle: Generator[None],
|
||||
event_key: EventKey,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test select functionality."""
|
||||
appliance.status.update(SETTINGS_STATUS)
|
||||
appliance.get_programs_available.return_value = [PROGRAM]
|
||||
get_appliances.return_value = [appliance]
|
||||
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
appliance.status.update(status)
|
||||
assert hass.states.is_state(entity_id, "unknown")
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
SERVICE_SELECT_OPTION,
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: program_to_set},
|
||||
blocking=True,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
getattr(client, mock_method).assert_awaited_once_with(
|
||||
appliance_ha_id, program_key=program_key
|
||||
)
|
||||
assert hass.states.is_state(entity_id, program_to_set)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
EventType.NOTIFY,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value="A not known program",
|
||||
)
|
||||
]
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, STATE_UNKNOWN)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"status",
|
||||
"program_to_set",
|
||||
"mock_attr",
|
||||
"exception_match",
|
||||
),
|
||||
[
|
||||
(
|
||||
"select.washer_selected_program",
|
||||
{BSH_SELECTED_PROGRAM: {"value": PROGRAM}},
|
||||
"select.dishwasher_selected_program",
|
||||
"dishcare_dishwasher_program_eco_50",
|
||||
"select_program",
|
||||
"set_selected_program",
|
||||
r"Error.*select.*program.*",
|
||||
),
|
||||
(
|
||||
"select.washer_active_program",
|
||||
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
|
||||
"select.dishwasher_active_program",
|
||||
"dishcare_dishwasher_program_eco_50",
|
||||
"start_program",
|
||||
r"Error.*start.*program.*",
|
||||
@@ -164,32 +192,36 @@ async def test_select_functionality(
|
||||
)
|
||||
async def test_select_exception_handling(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
program_to_set: str,
|
||||
mock_attr: str,
|
||||
exception_match: str,
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
problematic_appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
problematic_appliance.get_programs_available.side_effect = None
|
||||
problematic_appliance.get_programs_available.return_value = [PROGRAM]
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
client_with_exception.get_available_programs.side_effect = None
|
||||
client_with_exception.get_available_programs.return_value = (
|
||||
ArrayOfAvailablePrograms(
|
||||
[
|
||||
EnumerateAvailableProgram(
|
||||
key=ProgramKey.DISHCARE_DISHWASHER_ECO_50,
|
||||
raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
# Assert that an exception is called.
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
await getattr(client_with_exception, mock_attr)()
|
||||
|
||||
problematic_appliance.status.update(status)
|
||||
with pytest.raises(HomeAssistantError, match=exception_match):
|
||||
await hass.services.async_call(
|
||||
SELECT_DOMAIN,
|
||||
@@ -197,4 +229,4 @@ async def test_select_exception_handling(
|
||||
{"entity_id": entity_id, "option": program_to_set},
|
||||
blocking=True,
|
||||
)
|
||||
assert getattr(problematic_appliance, mock_attr).call_count == 2
|
||||
assert getattr(client_with_exception, mock_attr).call_count == 2
|
||||
|
||||
@@ -1,75 +1,77 @@
|
||||
"""Tests for home_connect sensor entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfEvents,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
Status,
|
||||
StatusKey,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from homeconnect.api import HomeConnectAPI
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.home_connect.const import (
|
||||
BSH_DOOR_STATE,
|
||||
BSH_DOOR_STATE_CLOSED,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
BSH_EVENT_PRESENT_STATE_CONFIRMED,
|
||||
BSH_EVENT_PRESENT_STATE_OFF,
|
||||
BSH_EVENT_PRESENT_STATE_PRESENT,
|
||||
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_component import async_update_entity
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
TEST_HC_APP = "Dishwasher"
|
||||
|
||||
|
||||
EVENT_PROG_DELAYED_START = {
|
||||
"BSH.Common.Status.OperationState": {
|
||||
"value": "BSH.Common.EnumType.OperationState.DelayedStart"
|
||||
},
|
||||
}
|
||||
|
||||
EVENT_PROG_REMAIN_NO_VALUE = {
|
||||
"BSH.Common.Option.RemainingProgramTime": {},
|
||||
"BSH.Common.Status.OperationState": {
|
||||
"value": "BSH.Common.EnumType.OperationState.DelayedStart"
|
||||
EventType.STATUS: {
|
||||
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.DelayedStart",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
EVENT_PROG_RUN = {
|
||||
"BSH.Common.Option.RemainingProgramTime": {"value": "0"},
|
||||
"BSH.Common.Option.ProgramProgress": {"value": "60"},
|
||||
"BSH.Common.Status.OperationState": {
|
||||
"value": "BSH.Common.EnumType.OperationState.Run"
|
||||
EventType.STATUS: {
|
||||
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run",
|
||||
},
|
||||
EventType.EVENT: {
|
||||
EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0,
|
||||
EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 60,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
EVENT_PROG_UPDATE_1 = {
|
||||
"BSH.Common.Option.RemainingProgramTime": {"value": "0"},
|
||||
"BSH.Common.Option.ProgramProgress": {"value": "80"},
|
||||
"BSH.Common.Status.OperationState": {
|
||||
"value": "BSH.Common.EnumType.OperationState.Run"
|
||||
EventType.EVENT: {
|
||||
EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 0,
|
||||
EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 80,
|
||||
},
|
||||
EventType.STATUS: {
|
||||
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run",
|
||||
},
|
||||
}
|
||||
|
||||
EVENT_PROG_UPDATE_2 = {
|
||||
"BSH.Common.Option.RemainingProgramTime": {"value": "20"},
|
||||
"BSH.Common.Option.ProgramProgress": {"value": "99"},
|
||||
"BSH.Common.Status.OperationState": {
|
||||
"value": "BSH.Common.EnumType.OperationState.Run"
|
||||
EventType.EVENT: {
|
||||
EventKey.BSH_COMMON_OPTION_REMAINING_PROGRAM_TIME: 20,
|
||||
EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS: 99,
|
||||
},
|
||||
EventType.STATUS: {
|
||||
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Run",
|
||||
},
|
||||
}
|
||||
|
||||
EVENT_PROG_END = {
|
||||
"BSH.Common.Status.OperationState": {
|
||||
"value": "BSH.Common.EnumType.OperationState.Ready"
|
||||
EventType.STATUS: {
|
||||
EventKey.BSH_COMMON_STATUS_OPERATION_STATE: "BSH.Common.EnumType.OperationState.Ready",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -80,22 +82,19 @@ def platforms() -> list[str]:
|
||||
return [Platform.SENSOR]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_sensors(
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test sensor entities."""
|
||||
get_appliances.return_value = [appliance]
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
# Appliance program sequence with a delayed start.
|
||||
# Appliance_ha_id program sequence with a delayed start.
|
||||
PROGRAM_SEQUENCE_EVENTS = (
|
||||
EVENT_PROG_DELAYED_START,
|
||||
EVENT_PROG_RUN,
|
||||
@@ -130,7 +129,7 @@ ENTITY_ID_STATES = {
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True)
|
||||
@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("states", "event_run"),
|
||||
list(
|
||||
@@ -141,17 +140,16 @@ ENTITY_ID_STATES = {
|
||||
)
|
||||
),
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_event_sensors(
|
||||
appliance: Mock,
|
||||
client: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
states: tuple,
|
||||
event_run: dict,
|
||||
event_run: dict[EventType, dict[EventKey, str | int]],
|
||||
freezer: FrozenDateTimeFactory,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
) -> None:
|
||||
"""Test sequence for sensors that are only available after an event happens."""
|
||||
entity_ids = ENTITY_ID_STATES.keys()
|
||||
@@ -159,24 +157,48 @@ async def test_event_sensors(
|
||||
time_to_freeze = "2021-01-09 12:00:00+00:00"
|
||||
freezer.move_to(time_to_freeze)
|
||||
|
||||
get_appliances.return_value = [appliance]
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
appliance.get_programs_available = MagicMock(return_value=["dummy_program"])
|
||||
appliance.status.update(EVENT_PROG_DELAYED_START)
|
||||
assert await integration_setup()
|
||||
client.get_status.return_value.status.extend(
|
||||
Status(
|
||||
key=StatusKey(event_key.value),
|
||||
raw_key=event_key.value,
|
||||
value=value,
|
||||
)
|
||||
for event_key, value in EVENT_PROG_DELAYED_START[EventType.STATUS].items()
|
||||
)
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
appliance.status.update(event_run)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
event_type,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=value,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
for event_type, events in event_run.items()
|
||||
for event_key, value in events.items()
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
for entity_id, state in zip(entity_ids, states, strict=False):
|
||||
await async_update_entity(hass, entity_id)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, state)
|
||||
|
||||
|
||||
# Program sequence for SensorDeviceClass.TIMESTAMP edge cases.
|
||||
PROGRAM_SEQUENCE_EDGE_CASE = [
|
||||
EVENT_PROG_REMAIN_NO_VALUE,
|
||||
EVENT_PROG_DELAYED_START,
|
||||
EVENT_PROG_RUN,
|
||||
EVENT_PROG_END,
|
||||
EVENT_PROG_END,
|
||||
@@ -191,60 +213,86 @@ ENTITY_ID_EDGE_CASE_STATES = [
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", [TEST_HC_APP], indirect=True)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True)
|
||||
async def test_remaining_prog_time_edge_cases(
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Run program sequence to test edge cases for the remaining_prog_time entity."""
|
||||
get_appliances.return_value = [appliance]
|
||||
entity_id = "sensor.dishwasher_program_finish_time"
|
||||
time_to_freeze = "2021-01-09 12:00:00+00:00"
|
||||
freezer.move_to(time_to_freeze)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
appliance.get_programs_available = MagicMock(return_value=["dummy_program"])
|
||||
appliance.status.update(EVENT_PROG_REMAIN_NO_VALUE)
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
for (
|
||||
event,
|
||||
expected_state,
|
||||
) in zip(PROGRAM_SEQUENCE_EDGE_CASE, ENTITY_ID_EDGE_CASE_STATES, strict=False):
|
||||
appliance.status.update(event)
|
||||
await async_update_entity(hass, entity_id)
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
event_type,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=event_key.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=value,
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
for event_type, events in event.items()
|
||||
for event_key, value in events.items()
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
freezer.tick()
|
||||
assert hass.states.is_state(entity_id, expected_state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status_key", "event_value_update", "expected", "appliance"),
|
||||
(
|
||||
"entity_id",
|
||||
"event_key",
|
||||
"event_type",
|
||||
"event_value_update",
|
||||
"expected",
|
||||
"appliance_ha_id",
|
||||
),
|
||||
[
|
||||
(
|
||||
"sensor.dishwasher_door",
|
||||
BSH_DOOR_STATE,
|
||||
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
EventType.STATUS,
|
||||
BSH_DOOR_STATE_LOCKED,
|
||||
"locked",
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"sensor.dishwasher_door",
|
||||
BSH_DOOR_STATE,
|
||||
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
EventType.STATUS,
|
||||
BSH_DOOR_STATE_CLOSED,
|
||||
"closed",
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"sensor.dishwasher_door",
|
||||
BSH_DOOR_STATE,
|
||||
EventKey.BSH_COMMON_STATUS_DOOR_STATE,
|
||||
EventType.STATUS,
|
||||
BSH_DOOR_STATE_OPEN,
|
||||
"open",
|
||||
"Dishwasher",
|
||||
@@ -252,33 +300,38 @@ async def test_remaining_prog_time_edge_cases(
|
||||
(
|
||||
"sensor.fridgefreezer_freezer_door_alarm",
|
||||
"EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF",
|
||||
EventType.EVENT,
|
||||
"",
|
||||
"off",
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"sensor.fridgefreezer_freezer_door_alarm",
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
|
||||
EventType.EVENT,
|
||||
BSH_EVENT_PRESENT_STATE_OFF,
|
||||
"off",
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"sensor.fridgefreezer_freezer_door_alarm",
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
|
||||
EventType.EVENT,
|
||||
BSH_EVENT_PRESENT_STATE_PRESENT,
|
||||
"present",
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"sensor.fridgefreezer_freezer_door_alarm",
|
||||
REFRIGERATION_EVENT_DOOR_ALARM_FREEZER,
|
||||
EventKey.REFRIGERATION_FRIDGE_FREEZER_EVENT_DOOR_ALARM_FREEZER,
|
||||
EventType.EVENT,
|
||||
BSH_EVENT_PRESENT_STATE_CONFIRMED,
|
||||
"confirmed",
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"sensor.coffeemaker_bean_container_empty",
|
||||
EventType.EVENT,
|
||||
"EVENT_NOT_IN_STATUS_YET_SO_SET_TO_OFF",
|
||||
"",
|
||||
"off",
|
||||
@@ -286,52 +339,68 @@ async def test_remaining_prog_time_edge_cases(
|
||||
),
|
||||
(
|
||||
"sensor.coffeemaker_bean_container_empty",
|
||||
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
EventType.EVENT,
|
||||
BSH_EVENT_PRESENT_STATE_OFF,
|
||||
"off",
|
||||
"CoffeeMaker",
|
||||
),
|
||||
(
|
||||
"sensor.coffeemaker_bean_container_empty",
|
||||
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
EventType.EVENT,
|
||||
BSH_EVENT_PRESENT_STATE_PRESENT,
|
||||
"present",
|
||||
"CoffeeMaker",
|
||||
),
|
||||
(
|
||||
"sensor.coffeemaker_bean_container_empty",
|
||||
COFFEE_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
EventKey.CONSUMER_PRODUCTS_COFFEE_MAKER_EVENT_BEAN_CONTAINER_EMPTY,
|
||||
EventType.EVENT,
|
||||
BSH_EVENT_PRESENT_STATE_CONFIRMED,
|
||||
"confirmed",
|
||||
"CoffeeMaker",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_sensors_states(
|
||||
entity_id: str,
|
||||
status_key: str,
|
||||
event_key: EventKey,
|
||||
event_type: EventType,
|
||||
event_value_update: str,
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
expected: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Tests for Appliance alarm sensors."""
|
||||
appliance.status.update(
|
||||
HomeConnectAPI.json2dict(
|
||||
load_json_object_fixture("home_connect/status.json")["data"]["status"]
|
||||
)
|
||||
)
|
||||
get_appliances.return_value = [appliance]
|
||||
"""Tests for Appliance_ha_id alarm sensors."""
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
appliance.status.update({status_key: {"value": event_value_update}})
|
||||
await async_update_entity(hass, entity_id)
|
||||
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
appliance_ha_id,
|
||||
event_type,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=event_key,
|
||||
raw_key=str(event_key),
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=event_value_update,
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, expected)
|
||||
|
||||
@@ -1,24 +1,34 @@
|
||||
"""Tests for home_connect sensor entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from homeconnect.api import HomeConnectAppliance, HomeConnectError
|
||||
from aiohomeconnect.model import (
|
||||
ArrayOfSettings,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
GetSetting,
|
||||
ProgramKey,
|
||||
SettingKey,
|
||||
)
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.event import ArrayOfEvents, EventType
|
||||
from aiohomeconnect.model.program import (
|
||||
ArrayOfAvailablePrograms,
|
||||
EnumerateAvailableProgram,
|
||||
)
|
||||
from aiohomeconnect.model.setting import SettingConstraints
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import automation, script
|
||||
from homeassistant.components.automation import automations_with_entity
|
||||
from homeassistant.components.home_connect.const import (
|
||||
ATTR_ALLOWED_VALUES,
|
||||
ATTR_CONSTRAINTS,
|
||||
BSH_ACTIVE_PROGRAM,
|
||||
BSH_CHILD_LOCK_STATE,
|
||||
BSH_POWER_OFF,
|
||||
BSH_POWER_ON,
|
||||
BSH_POWER_STANDBY,
|
||||
BSH_POWER_STATE,
|
||||
DOMAIN,
|
||||
REFRIGERATION_SUPERMODEFREEZER,
|
||||
)
|
||||
from homeassistant.components.script import scripts_with_entity
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
@@ -36,19 +46,7 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .conftest import get_all_appliances
|
||||
|
||||
from tests.common import MockConfigEntry, load_json_object_fixture
|
||||
|
||||
SETTINGS_STATUS = {
|
||||
setting.pop("key"): setting
|
||||
for setting in load_json_object_fixture("home_connect/settings.json")
|
||||
.get("Dishwasher")
|
||||
.get("data")
|
||||
.get("settings")
|
||||
}
|
||||
|
||||
PROGRAM = "LaundryCare.Dryer.Program.Mix"
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -58,231 +56,285 @@ def platforms() -> list[str]:
|
||||
|
||||
|
||||
async def test_switches(
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch entities."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status", "service", "state", "appliance"),
|
||||
[
|
||||
(
|
||||
"switch.dishwasher_program_mix",
|
||||
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
|
||||
SERVICE_TURN_ON,
|
||||
STATE_ON,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_program_mix",
|
||||
{BSH_ACTIVE_PROGRAM: {"value": ""}},
|
||||
SERVICE_TURN_OFF,
|
||||
STATE_OFF,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_child_lock",
|
||||
{BSH_CHILD_LOCK_STATE: {"value": True}},
|
||||
SERVICE_TURN_ON,
|
||||
STATE_ON,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_child_lock",
|
||||
{BSH_CHILD_LOCK_STATE: {"value": False}},
|
||||
SERVICE_TURN_OFF,
|
||||
STATE_OFF,
|
||||
"Dishwasher",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
)
|
||||
async def test_switch_functionality(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
service: str,
|
||||
state: str,
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch functionality."""
|
||||
appliance.status.update(SETTINGS_STATUS)
|
||||
appliance.get_programs_available.return_value = [PROGRAM]
|
||||
get_appliances.return_value = [appliance]
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
appliance.status.update(status)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
|
||||
)
|
||||
assert hass.states.is_state(entity_id, state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"status",
|
||||
"service",
|
||||
"settings_key_arg",
|
||||
"setting_value_arg",
|
||||
"state",
|
||||
"appliance_ha_id",
|
||||
),
|
||||
[
|
||||
(
|
||||
"switch.dishwasher_child_lock",
|
||||
SERVICE_TURN_ON,
|
||||
SettingKey.BSH_COMMON_CHILD_LOCK,
|
||||
True,
|
||||
STATE_ON,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_child_lock",
|
||||
SERVICE_TURN_OFF,
|
||||
SettingKey.BSH_COMMON_CHILD_LOCK,
|
||||
False,
|
||||
STATE_OFF,
|
||||
"Dishwasher",
|
||||
),
|
||||
],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_switch_functionality(
|
||||
entity_id: str,
|
||||
settings_key_arg: SettingKey,
|
||||
setting_value_arg: Any,
|
||||
service: str,
|
||||
state: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance_ha_id: str,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch functionality."""
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
|
||||
await hass.async_block_till_done()
|
||||
client.set_setting.assert_awaited_once_with(
|
||||
appliance_ha_id, setting_key=settings_key_arg, value=setting_value_arg
|
||||
)
|
||||
assert hass.states.is_state(entity_id, state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "program_key", "appliance_ha_id"),
|
||||
[
|
||||
(
|
||||
"switch.dryer_program_mix",
|
||||
ProgramKey.LAUNDRY_CARE_DRYER_MIX,
|
||||
"Dryer",
|
||||
),
|
||||
],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_program_switch_functionality(
|
||||
entity_id: str,
|
||||
program_key: ProgramKey,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance_ha_id: str,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch functionality."""
|
||||
|
||||
async def mock_stop_program(ha_id: str) -> None:
|
||||
"""Mock stop program."""
|
||||
await client.add_events(
|
||||
[
|
||||
EventMessage(
|
||||
ha_id,
|
||||
EventType.NOTIFY,
|
||||
ArrayOfEvents(
|
||||
[
|
||||
Event(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
raw_key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
|
||||
timestamp=0,
|
||||
level="",
|
||||
handling="",
|
||||
value=ProgramKey.UNKNOWN,
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
client.stop_program = AsyncMock(side_effect=mock_stop_program)
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, STATE_ON)
|
||||
client.start_program.assert_awaited_once_with(
|
||||
appliance_ha_id, program_key=program_key
|
||||
)
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, STATE_OFF)
|
||||
client.stop_program.assert_awaited_once_with(appliance_ha_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"entity_id",
|
||||
"service",
|
||||
"mock_attr",
|
||||
"problematic_appliance",
|
||||
"exception_match",
|
||||
),
|
||||
[
|
||||
(
|
||||
"switch.dishwasher_program_mix",
|
||||
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
|
||||
"switch.dishwasher_program_eco50",
|
||||
SERVICE_TURN_ON,
|
||||
"start_program",
|
||||
"Dishwasher",
|
||||
r"Error.*start.*program.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_program_mix",
|
||||
{BSH_ACTIVE_PROGRAM: {"value": PROGRAM}},
|
||||
"switch.dishwasher_program_eco50",
|
||||
SERVICE_TURN_OFF,
|
||||
"stop_program",
|
||||
"Dishwasher",
|
||||
r"Error.*stop.*program.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
{BSH_POWER_STATE: {"value": BSH_POWER_OFF}},
|
||||
SERVICE_TURN_OFF,
|
||||
"set_setting",
|
||||
"Dishwasher",
|
||||
r"Error.*turn.*off.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
{BSH_POWER_STATE: {"value": ""}},
|
||||
SERVICE_TURN_ON,
|
||||
"set_setting",
|
||||
"Dishwasher",
|
||||
r"Error.*turn.*on.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_child_lock",
|
||||
{BSH_CHILD_LOCK_STATE: {"value": ""}},
|
||||
SERVICE_TURN_ON,
|
||||
"set_setting",
|
||||
"Dishwasher",
|
||||
r"Error.*turn.*on.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_child_lock",
|
||||
{BSH_CHILD_LOCK_STATE: {"value": ""}},
|
||||
SERVICE_TURN_OFF,
|
||||
"set_setting",
|
||||
"Dishwasher",
|
||||
r"Error.*turn.*off.*",
|
||||
),
|
||||
],
|
||||
indirect=["problematic_appliance"],
|
||||
)
|
||||
async def test_switch_exception_handling(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
service: str,
|
||||
mock_attr: str,
|
||||
exception_match: str,
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
problematic_appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test exception handling."""
|
||||
problematic_appliance.get_programs_available.side_effect = None
|
||||
problematic_appliance.get_programs_available.return_value = [PROGRAM]
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
client_with_exception.get_available_programs.side_effect = None
|
||||
client_with_exception.get_available_programs.return_value = (
|
||||
ArrayOfAvailablePrograms(
|
||||
[
|
||||
EnumerateAvailableProgram(
|
||||
key=ProgramKey.DISHCARE_DISHWASHER_ECO_50,
|
||||
raw_key=ProgramKey.DISHCARE_DISHWASHER_ECO_50.value,
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
client_with_exception.get_settings.side_effect = None
|
||||
client_with_exception.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=SettingKey.BSH_COMMON_CHILD_LOCK,
|
||||
raw_key=SettingKey.BSH_COMMON_CHILD_LOCK.value,
|
||||
value=False,
|
||||
),
|
||||
GetSetting(
|
||||
key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
|
||||
value=BSH_POWER_ON,
|
||||
constraints=SettingConstraints(
|
||||
allowed_values=[BSH_POWER_ON, BSH_POWER_OFF]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
problematic_appliance.status.update(status)
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
# Assert that an exception is called.
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
await getattr(client_with_exception, mock_attr)()
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=exception_match):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
|
||||
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert getattr(problematic_appliance, mock_attr).call_count == 2
|
||||
assert getattr(client_with_exception, mock_attr).call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status", "service", "state", "appliance"),
|
||||
("entity_id", "status", "service", "state", "appliance_ha_id"),
|
||||
[
|
||||
(
|
||||
"switch.fridgefreezer_freezer_super_mode",
|
||||
{REFRIGERATION_SUPERMODEFREEZER: {"value": True}},
|
||||
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: True},
|
||||
SERVICE_TURN_ON,
|
||||
STATE_ON,
|
||||
"FridgeFreezer",
|
||||
),
|
||||
(
|
||||
"switch.fridgefreezer_freezer_super_mode",
|
||||
{REFRIGERATION_SUPERMODEFREEZER: {"value": False}},
|
||||
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: False},
|
||||
SERVICE_TURN_OFF,
|
||||
STATE_OFF,
|
||||
"FridgeFreezer",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_ent_desc_switch_functionality(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
service: str,
|
||||
state: str,
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch functionality - entity description setup."""
|
||||
appliance.status.update(
|
||||
HomeConnectAppliance.json2dict(
|
||||
load_json_object_fixture("home_connect/settings.json")
|
||||
.get(appliance.name)
|
||||
.get("data")
|
||||
.get("settings")
|
||||
)
|
||||
)
|
||||
get_appliances.return_value = [appliance]
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
appliance.status.update(status)
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.is_state(entity_id, state)
|
||||
|
||||
|
||||
@@ -292,13 +344,13 @@ async def test_ent_desc_switch_functionality(
|
||||
"status",
|
||||
"service",
|
||||
"mock_attr",
|
||||
"problematic_appliance",
|
||||
"appliance_ha_id",
|
||||
"exception_match",
|
||||
),
|
||||
[
|
||||
(
|
||||
"switch.fridgefreezer_freezer_super_mode",
|
||||
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}},
|
||||
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""},
|
||||
SERVICE_TURN_ON,
|
||||
"set_setting",
|
||||
"FridgeFreezer",
|
||||
@@ -306,203 +358,257 @@ async def test_ent_desc_switch_functionality(
|
||||
),
|
||||
(
|
||||
"switch.fridgefreezer_freezer_super_mode",
|
||||
{REFRIGERATION_SUPERMODEFREEZER: {"value": ""}},
|
||||
{SettingKey.REFRIGERATION_FRIDGE_FREEZER_SUPER_MODE_FREEZER: ""},
|
||||
SERVICE_TURN_OFF,
|
||||
"set_setting",
|
||||
"FridgeFreezer",
|
||||
r"Error.*turn.*off.*",
|
||||
),
|
||||
],
|
||||
indirect=["problematic_appliance"],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
async def test_ent_desc_switch_exception_handling(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
status: dict[SettingKey, str],
|
||||
service: str,
|
||||
mock_attr: str,
|
||||
exception_match: str,
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
config_entry: MockConfigEntry,
|
||||
setup_credentials: None,
|
||||
problematic_appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch exception handling - entity description setup."""
|
||||
problematic_appliance.status.update(
|
||||
HomeConnectAppliance.json2dict(
|
||||
load_json_object_fixture("home_connect/settings.json")
|
||||
.get(problematic_appliance.name)
|
||||
.get("data")
|
||||
.get("settings")
|
||||
)
|
||||
client_with_exception.get_settings.side_effect = None
|
||||
client_with_exception.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=key,
|
||||
raw_key=key.value,
|
||||
value=value,
|
||||
)
|
||||
for key, value in status.items()
|
||||
]
|
||||
)
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
# Assert that an exception is called.
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
|
||||
problematic_appliance.status.update(status)
|
||||
await client_with_exception.set_setting()
|
||||
with pytest.raises(HomeAssistantError, match=exception_match):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert getattr(problematic_appliance, mock_attr).call_count == 2
|
||||
assert client_with_exception.set_setting.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "status", "allowed_values", "service", "power_state", "appliance"),
|
||||
(
|
||||
"entity_id",
|
||||
"allowed_values",
|
||||
"service",
|
||||
"setting_value_arg",
|
||||
"power_state",
|
||||
"appliance_ha_id",
|
||||
),
|
||||
[
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
{BSH_POWER_STATE: {"value": BSH_POWER_ON}},
|
||||
[BSH_POWER_ON, BSH_POWER_OFF],
|
||||
SERVICE_TURN_ON,
|
||||
BSH_POWER_ON,
|
||||
STATE_ON,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
{BSH_POWER_STATE: {"value": BSH_POWER_OFF}},
|
||||
[BSH_POWER_ON, BSH_POWER_OFF],
|
||||
SERVICE_TURN_OFF,
|
||||
BSH_POWER_OFF,
|
||||
STATE_OFF,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
{BSH_POWER_STATE: {"value": BSH_POWER_ON}},
|
||||
[BSH_POWER_ON, BSH_POWER_STANDBY],
|
||||
SERVICE_TURN_ON,
|
||||
BSH_POWER_ON,
|
||||
STATE_ON,
|
||||
"Dishwasher",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
{BSH_POWER_STATE: {"value": BSH_POWER_STANDBY}},
|
||||
[BSH_POWER_ON, BSH_POWER_STANDBY],
|
||||
SERVICE_TURN_OFF,
|
||||
BSH_POWER_STANDBY,
|
||||
STATE_OFF,
|
||||
"Dishwasher",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
indirect=["appliance_ha_id"],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_power_swtich(
|
||||
entity_id: str,
|
||||
status: dict,
|
||||
allowed_values: list[str],
|
||||
allowed_values: list[str | None] | None,
|
||||
service: str,
|
||||
setting_value_arg: str,
|
||||
power_state: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance: Mock,
|
||||
get_appliances: MagicMock,
|
||||
appliance_ha_id: str,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test power switch functionality."""
|
||||
appliance.get.side_effect = [
|
||||
{
|
||||
ATTR_CONSTRAINTS: {
|
||||
ATTR_ALLOWED_VALUES: allowed_values,
|
||||
},
|
||||
}
|
||||
]
|
||||
appliance.status.update(SETTINGS_STATUS)
|
||||
appliance.status.update(status)
|
||||
get_appliances.return_value = [appliance]
|
||||
client.get_settings.side_effect = None
|
||||
client.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
|
||||
value="",
|
||||
constraints=SettingConstraints(
|
||||
allowed_values=allowed_values,
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
await hass.services.async_call(SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id})
|
||||
await hass.async_block_till_done()
|
||||
client.set_setting.assert_awaited_once_with(
|
||||
appliance_ha_id,
|
||||
setting_key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
value=setting_value_arg,
|
||||
)
|
||||
assert hass.states.is_state(entity_id, power_state)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "allowed_values", "service", "appliance", "exception_match"),
|
||||
("initial_value"),
|
||||
[
|
||||
(BSH_POWER_OFF),
|
||||
(BSH_POWER_STANDBY),
|
||||
],
|
||||
)
|
||||
async def test_power_switch_fetch_off_state_from_current_value(
|
||||
initial_value: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test power switch functionality to fetch the off state from the current value."""
|
||||
client.get_settings.side_effect = None
|
||||
client.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
|
||||
value=initial_value,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert hass.states.is_state("switch.dishwasher_power", STATE_OFF)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "allowed_values", "service", "exception_match"),
|
||||
[
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
[BSH_POWER_ON],
|
||||
SERVICE_TURN_OFF,
|
||||
"Dishwasher",
|
||||
r".*not support.*turn.*off.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
None,
|
||||
SERVICE_TURN_OFF,
|
||||
"Dishwasher",
|
||||
r".*Unable.*turn.*off.*support.*not.*determined.*",
|
||||
),
|
||||
(
|
||||
"switch.dishwasher_power",
|
||||
HomeConnectError(),
|
||||
SERVICE_TURN_OFF,
|
||||
r".*Unable.*turn.*off.*support.*not.*determined.*",
|
||||
),
|
||||
],
|
||||
indirect=["appliance"],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_power_switch_service_validation_errors(
|
||||
entity_id: str,
|
||||
allowed_values: list[str],
|
||||
allowed_values: list[str | None] | None | HomeConnectError,
|
||||
service: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
appliance: Mock,
|
||||
exception_match: str,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test power switch functionality validation errors."""
|
||||
if allowed_values:
|
||||
appliance.get.side_effect = [
|
||||
{
|
||||
ATTR_CONSTRAINTS: {
|
||||
ATTR_ALLOWED_VALUES: allowed_values,
|
||||
},
|
||||
}
|
||||
]
|
||||
appliance.status.update(SETTINGS_STATUS)
|
||||
get_appliances.return_value = [appliance]
|
||||
client.get_settings.side_effect = None
|
||||
if isinstance(allowed_values, HomeConnectError):
|
||||
exception = allowed_values
|
||||
client.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
|
||||
value=BSH_POWER_ON,
|
||||
)
|
||||
]
|
||||
)
|
||||
client.get_setting = AsyncMock(side_effect=exception)
|
||||
else:
|
||||
setting = GetSetting(
|
||||
key=SettingKey.BSH_COMMON_POWER_STATE,
|
||||
raw_key=SettingKey.BSH_COMMON_POWER_STATE.value,
|
||||
value=BSH_POWER_ON,
|
||||
constraints=SettingConstraints(
|
||||
allowed_values=allowed_values,
|
||||
),
|
||||
)
|
||||
client.get_settings.return_value = ArrayOfSettings([setting])
|
||||
client.get_setting = AsyncMock(return_value=setting)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
appliance.status.update({BSH_POWER_STATE: {"value": BSH_POWER_ON}})
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=exception_match):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN, service, {"entity_id": entity_id}, blocking=True
|
||||
SWITCH_DOMAIN, service, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_create_issue(
|
||||
hass: HomeAssistant,
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test we create an issue when an automation or script is using a deprecated entity."""
|
||||
entity_id = "switch.washer_program_mix"
|
||||
appliance.status.update(SETTINGS_STATUS)
|
||||
appliance.get_programs_available.return_value = [PROGRAM]
|
||||
get_appliances.return_value = [appliance]
|
||||
issue_id = f"deprecated_program_switch_{entity_id}"
|
||||
|
||||
assert await async_setup_component(
|
||||
@@ -539,7 +645,7 @@ async def test_create_issue(
|
||||
)
|
||||
|
||||
assert config_entry.state == ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state == ConfigEntryState.LOADED
|
||||
|
||||
assert automations_with_entity(hass, entity_id)[0] == "automation.test"
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
"""Tests for home_connect time entities."""
|
||||
|
||||
from collections.abc import Awaitable, Callable, Generator
|
||||
from collections.abc import Awaitable, Callable
|
||||
from datetime import time
|
||||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from homeconnect.api import HomeConnectError
|
||||
from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.home_connect.const import ATTR_VALUE
|
||||
from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import get_all_appliances
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@@ -26,114 +24,98 @@ def platforms() -> list[str]:
|
||||
|
||||
|
||||
async def test_time(
|
||||
bypass_throttle: Generator[None],
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: Mock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test time entity."""
|
||||
get_appliances.side_effect = get_all_appliances
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
|
||||
@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "setting_key", "setting_value", "expected_state"),
|
||||
("entity_id", "setting_key"),
|
||||
[
|
||||
(
|
||||
f"{TIME_DOMAIN}.oven_alarm_clock",
|
||||
"BSH.Common.Setting.AlarmClock",
|
||||
{ATTR_VALUE: 59},
|
||||
str(time(second=59)),
|
||||
),
|
||||
(
|
||||
f"{TIME_DOMAIN}.oven_alarm_clock",
|
||||
"BSH.Common.Setting.AlarmClock",
|
||||
{ATTR_VALUE: None},
|
||||
"unknown",
|
||||
),
|
||||
(
|
||||
f"{TIME_DOMAIN}.oven_alarm_clock",
|
||||
"BSH.Common.Setting.AlarmClock",
|
||||
None,
|
||||
"unknown",
|
||||
SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_time_entity_functionality(
|
||||
appliance: Mock,
|
||||
appliance_ha_id: str,
|
||||
entity_id: str,
|
||||
setting_key: str,
|
||||
setting_value: dict,
|
||||
expected_state: str,
|
||||
bypass_throttle: Generator[None],
|
||||
setting_key: SettingKey,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client: MagicMock,
|
||||
) -> None:
|
||||
"""Test time entity functionality."""
|
||||
get_appliances.return_value = [appliance]
|
||||
appliance.status.update({setting_key: setting_value})
|
||||
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
assert hass.states.is_state(entity_id, expected_state)
|
||||
|
||||
new_value = 30
|
||||
assert hass.states.get(entity_id).state != new_value
|
||||
value = 30
|
||||
entity_state = hass.states.get(entity_id)
|
||||
assert entity_state is not None
|
||||
assert entity_state.state != value
|
||||
await hass.services.async_call(
|
||||
TIME_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{
|
||||
ATTR_ENTITY_ID: entity_id,
|
||||
ATTR_TIME: time(second=new_value),
|
||||
ATTR_TIME: time(second=value),
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
appliance.set_setting.assert_called_once_with(setting_key, new_value)
|
||||
await hass.async_block_till_done()
|
||||
client.set_setting.assert_awaited_once_with(
|
||||
appliance_ha_id, setting_key=setting_key, value=value
|
||||
)
|
||||
assert hass.states.is_state(entity_id, str(time(second=value)))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("problematic_appliance", ["Oven"], indirect=True)
|
||||
@pytest.mark.parametrize(
|
||||
("entity_id", "setting_key", "mock_attr"),
|
||||
[
|
||||
(
|
||||
f"{TIME_DOMAIN}.oven_alarm_clock",
|
||||
"BSH.Common.Setting.AlarmClock",
|
||||
SettingKey.BSH_COMMON_ALARM_CLOCK,
|
||||
"set_setting",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("bypass_throttle")
|
||||
async def test_time_entity_error(
|
||||
problematic_appliance: Mock,
|
||||
entity_id: str,
|
||||
setting_key: str,
|
||||
setting_key: SettingKey,
|
||||
mock_attr: str,
|
||||
hass: HomeAssistant,
|
||||
config_entry: MockConfigEntry,
|
||||
integration_setup: Callable[[], Awaitable[bool]],
|
||||
integration_setup: Callable[[MagicMock], Awaitable[bool]],
|
||||
setup_credentials: None,
|
||||
get_appliances: MagicMock,
|
||||
client_with_exception: MagicMock,
|
||||
) -> None:
|
||||
"""Test time entity error."""
|
||||
get_appliances.return_value = [problematic_appliance]
|
||||
|
||||
client_with_exception.get_settings.side_effect = None
|
||||
client_with_exception.get_settings.return_value = ArrayOfSettings(
|
||||
[
|
||||
GetSetting(
|
||||
key=setting_key,
|
||||
raw_key=setting_key.value,
|
||||
value=30,
|
||||
)
|
||||
]
|
||||
)
|
||||
assert config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
problematic_appliance.status.update({setting_key: {}})
|
||||
assert await integration_setup()
|
||||
assert await integration_setup(client_with_exception)
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
with pytest.raises(HomeConnectError):
|
||||
getattr(problematic_appliance, mock_attr)()
|
||||
await getattr(client_with_exception, mock_attr)()
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match=r"Error.*assign.*value.*to.*setting.*"
|
||||
@@ -147,4 +129,4 @@ async def test_time_entity_error(
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
assert getattr(problematic_appliance, mock_attr).call_count == 2
|
||||
assert getattr(client_with_exception, mock_attr).call_count == 2
|
||||
|
||||
@@ -88,7 +88,7 @@ def mock_adapter() -> Generator[MagicMock]:
|
||||
status_code=200,
|
||||
json={
|
||||
"parentReference": {"driveId": "mock_drive_id"},
|
||||
"shared": {"owner": {"user": {"displayName": "John Doe"}}},
|
||||
"createdBy": {"user": {"displayName": "John Doe"}},
|
||||
},
|
||||
)
|
||||
yield adapter
|
||||
|
||||
@@ -156,6 +156,28 @@ async def test_agents_delete(
|
||||
mock_drive_items.delete.assert_called_once()
|
||||
|
||||
|
||||
async def test_agents_delete_not_found_does_not_throw(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
mock_drive_items: MagicMock,
|
||||
) -> None:
|
||||
"""Test agent delete backup."""
|
||||
mock_drive_items.delete = AsyncMock(side_effect=APIError(response_status_code=404))
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
await client.send_json_auto_id(
|
||||
{
|
||||
"type": "backup/delete",
|
||||
"backup_id": BACKUP_METADATA["backup_id"],
|
||||
}
|
||||
)
|
||||
response = await client.receive_json()
|
||||
|
||||
assert response["success"]
|
||||
assert response["result"] == {"agent_errors": {}}
|
||||
mock_drive_items.delete.assert_called_once()
|
||||
|
||||
|
||||
async def test_agents_upload(
|
||||
hass_client: ClientSessionGenerator,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
@@ -257,7 +279,7 @@ async def test_agents_download(
|
||||
("side_effect", "error"),
|
||||
[
|
||||
(
|
||||
APIError(response_status_code=404, message="File not found."),
|
||||
APIError(response_status_code=500),
|
||||
"Backup operation failed",
|
||||
),
|
||||
(TimeoutError(), "Backup operation timed out"),
|
||||
|
||||
@@ -65,6 +65,19 @@ MOCK_OWPROXY_DEVICES = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"20.111111111111": {
|
||||
ATTR_INJECT_READS: {
|
||||
"/type": [b"DS2450"],
|
||||
"/volt.A": [b" 1.1"],
|
||||
"/volt.B": [b" 2.2"],
|
||||
"/volt.C": [b" 3.3"],
|
||||
"/volt.D": [b" 4.4"],
|
||||
"/latestvolt.A": [b" 1.11"],
|
||||
"/latestvolt.B": [b" 2.22"],
|
||||
"/latestvolt.C": [b" 3.33"],
|
||||
"/latestvolt.D": [b" 4.44"],
|
||||
}
|
||||
},
|
||||
"22.111111111111": {
|
||||
ATTR_INJECT_READS: {
|
||||
"/type": [b"DS1822"],
|
||||
|
||||
@@ -159,6 +159,38 @@
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_registry[20.111111111111-entry]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
'config_entries': <ANY>,
|
||||
'configuration_url': None,
|
||||
'connections': set({
|
||||
}),
|
||||
'disabled_by': None,
|
||||
'entry_type': None,
|
||||
'hw_version': None,
|
||||
'id': <ANY>,
|
||||
'identifiers': set({
|
||||
tuple(
|
||||
'onewire',
|
||||
'20.111111111111',
|
||||
),
|
||||
}),
|
||||
'is_new': False,
|
||||
'labels': set({
|
||||
}),
|
||||
'manufacturer': 'Maxim Integrated',
|
||||
'model': 'DS2450',
|
||||
'model_id': 'DS2450',
|
||||
'name': '20.111111111111',
|
||||
'name_by_user': None,
|
||||
'primary_config_entry': <ANY>,
|
||||
'serial_number': '111111111111',
|
||||
'suggested_area': None,
|
||||
'sw_version': None,
|
||||
'via_device_id': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_registry[22.111111111111-entry]
|
||||
DeviceRegistryEntrySnapshot({
|
||||
'area_id': None,
|
||||
|
||||
@@ -260,6 +260,430 @@
|
||||
'state': '248125',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_a-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_a',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Latest voltage A',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'latest_voltage_id',
|
||||
'unique_id': '/20.111111111111/latestvolt.A',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_a-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/latestvolt.A',
|
||||
'friendly_name': '20.111111111111 Latest voltage A',
|
||||
'raw_value': 1.11,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_a',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.11',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_b-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_b',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Latest voltage B',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'latest_voltage_id',
|
||||
'unique_id': '/20.111111111111/latestvolt.B',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_b-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/latestvolt.B',
|
||||
'friendly_name': '20.111111111111 Latest voltage B',
|
||||
'raw_value': 2.22,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_b',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '2.22',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_c-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_c',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Latest voltage C',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'latest_voltage_id',
|
||||
'unique_id': '/20.111111111111/latestvolt.C',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_c-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/latestvolt.C',
|
||||
'friendly_name': '20.111111111111 Latest voltage C',
|
||||
'raw_value': 3.33,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_c',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3.33',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_d-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_d',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Latest voltage D',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'latest_voltage_id',
|
||||
'unique_id': '/20.111111111111/latestvolt.D',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_latest_voltage_d-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/latestvolt.D',
|
||||
'friendly_name': '20.111111111111 Latest voltage D',
|
||||
'raw_value': 4.44,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_latest_voltage_d',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '4.44',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_a-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_a',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Voltage A',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'voltage_id',
|
||||
'unique_id': '/20.111111111111/volt.A',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_a-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/volt.A',
|
||||
'friendly_name': '20.111111111111 Voltage A',
|
||||
'raw_value': 1.1,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_a',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '1.1',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_b-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_b',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Voltage B',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'voltage_id',
|
||||
'unique_id': '/20.111111111111/volt.B',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_b-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/volt.B',
|
||||
'friendly_name': '20.111111111111 Voltage B',
|
||||
'raw_value': 2.2,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_b',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '2.2',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_c-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_c',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Voltage C',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'voltage_id',
|
||||
'unique_id': '/20.111111111111/volt.C',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_c-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/volt.C',
|
||||
'friendly_name': '20.111111111111 Voltage C',
|
||||
'raw_value': 3.3,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_c',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '3.3',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_d-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_d',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLTAGE: 'voltage'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Voltage D',
|
||||
'platform': 'onewire',
|
||||
'previous_unique_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'voltage_id',
|
||||
'unique_id': '/20.111111111111/volt.D',
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.20_111111111111_voltage_d-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'voltage',
|
||||
'device_file': '/20.111111111111/volt.D',
|
||||
'friendly_name': '20.111111111111 Voltage D',
|
||||
'raw_value': 4.4,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfElectricPotential.VOLT: 'V'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.20_111111111111_voltage_d',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '4.4',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[sensor.22_111111111111_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user