mirror of
https://github.com/home-assistant/core.git
synced 2025-08-04 21:25:13 +02:00
Merge branch 'dev' into Power_Energy_values
This commit is contained in:
2
.github/workflows/builder.yml
vendored
2
.github/workflows/builder.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
||||
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
22
.github/workflows/ci.yaml
vendored
22
.github/workflows/ci.yaml
vendored
@@ -537,7 +537,7 @@ jobs:
|
||||
python --version
|
||||
uv pip freeze >> pip_freeze.txt
|
||||
- name: Upload pip_freeze artifact
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pip-freeze-${{ matrix.python-version }}
|
||||
path: pip_freeze.txt
|
||||
@@ -661,7 +661,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
|
||||
- name: Upload licenses
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
|
||||
path: licenses-${{ matrix.python-version }}.json
|
||||
@@ -877,7 +877,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
|
||||
- name: Upload pytest_buckets
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest_buckets
|
||||
path: pytest_buckets.txt
|
||||
@@ -980,14 +980,14 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-full.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
@@ -1108,7 +1108,7 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1116,7 +1116,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.mariadb }}
|
||||
@@ -1239,7 +1239,7 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1247,7 +1247,7 @@ jobs:
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{
|
||||
steps.pytest-partial.outputs.postgresql }}
|
||||
@@ -1382,14 +1382,14 @@ jobs:
|
||||
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
|
||||
- name: Upload pytest output
|
||||
if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: pytest-*.txt
|
||||
overwrite: true
|
||||
- name: Upload coverage artifact
|
||||
if: needs.info.outputs.skip_coverage != 'true'
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
|
||||
path: coverage.xml
|
||||
|
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -24,11 +24,11 @@ jobs:
|
||||
uses: actions/checkout@v4.2.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3.28.9
|
||||
uses: github/codeql-action/init@v3.28.10
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3.28.9
|
||||
uses: github/codeql-action/analyze@v3.28.10
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
8
.github/workflows/wheels.yml
vendored
8
.github/workflows/wheels.yml
vendored
@@ -91,7 +91,7 @@ jobs:
|
||||
) > build_constraints.txt
|
||||
|
||||
- name: Upload env_file
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: env_file
|
||||
path: ./.env_file
|
||||
@@ -99,14 +99,14 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
- name: Upload build_constraints
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: build_constraints
|
||||
path: ./build_constraints.txt
|
||||
overwrite: true
|
||||
|
||||
- name: Upload requirements_diff
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: requirements_diff
|
||||
path: ./requirements_diff.txt
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
python -m script.gen_requirements_all ci
|
||||
|
||||
- name: Upload requirements_all_wheels
|
||||
uses: actions/upload-artifact@v4.6.0
|
||||
uses: actions/upload-artifact@v4.6.1
|
||||
with:
|
||||
name: requirements_all_wheels
|
||||
path: ./requirements_all_wheels_*.txt
|
||||
|
@@ -103,6 +103,7 @@ homeassistant.components.auth.*
|
||||
homeassistant.components.automation.*
|
||||
homeassistant.components.awair.*
|
||||
homeassistant.components.axis.*
|
||||
homeassistant.components.azure_storage.*
|
||||
homeassistant.components.backup.*
|
||||
homeassistant.components.baf.*
|
||||
homeassistant.components.bang_olufsen.*
|
||||
@@ -407,6 +408,7 @@ homeassistant.components.raspberry_pi.*
|
||||
homeassistant.components.rdw.*
|
||||
homeassistant.components.recollect_waste.*
|
||||
homeassistant.components.recorder.*
|
||||
homeassistant.components.remember_the_milk.*
|
||||
homeassistant.components.remote.*
|
||||
homeassistant.components.renault.*
|
||||
homeassistant.components.reolink.*
|
||||
|
14
CODEOWNERS
generated
14
CODEOWNERS
generated
@@ -180,6 +180,8 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/azure_event_hub/ @eavanvalkenburg
|
||||
/tests/components/azure_event_hub/ @eavanvalkenburg
|
||||
/homeassistant/components/azure_service_bus/ @hfurubotten
|
||||
/homeassistant/components/azure_storage/ @zweckj
|
||||
/tests/components/azure_storage/ @zweckj
|
||||
/homeassistant/components/backup/ @home-assistant/core
|
||||
/tests/components/backup/ @home-assistant/core
|
||||
/homeassistant/components/baf/ @bdraco @jfroy
|
||||
@@ -967,8 +969,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/motionblinds_ble/ @LennP @jerrybboy
|
||||
/homeassistant/components/motioneye/ @dermotduffy
|
||||
/tests/components/motioneye/ @dermotduffy
|
||||
/homeassistant/components/motionmount/ @RJPoelstra
|
||||
/tests/components/motionmount/ @RJPoelstra
|
||||
/homeassistant/components/motionmount/ @laiho-vogels
|
||||
/tests/components/motionmount/ @laiho-vogels
|
||||
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco
|
||||
/homeassistant/components/msteams/ @peroyvind
|
||||
@@ -1051,8 +1053,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/numato/ @clssn
|
||||
/homeassistant/components/number/ @home-assistant/core @Shulyaka
|
||||
/tests/components/number/ @home-assistant/core @Shulyaka
|
||||
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez
|
||||
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
|
||||
/tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
|
||||
/homeassistant/components/nws/ @MatthewFlamm @kamiyo
|
||||
/tests/components/nws/ @MatthewFlamm @kamiyo
|
||||
/homeassistant/components/nyt_games/ @joostlek
|
||||
@@ -1413,6 +1415,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/snapcast/ @luar123
|
||||
/homeassistant/components/snmp/ @nmaggioni
|
||||
/tests/components/snmp/ @nmaggioni
|
||||
/homeassistant/components/snoo/ @Lash-L
|
||||
/tests/components/snoo/ @Lash-L
|
||||
/homeassistant/components/snooz/ @AustinBrunkhorst
|
||||
/tests/components/snooz/ @AustinBrunkhorst
|
||||
/homeassistant/components/solaredge/ @frenck @bdraco
|
||||
@@ -1693,6 +1697,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/weatherflow_cloud/ @jeeftor
|
||||
/homeassistant/components/weatherkit/ @tjhorner
|
||||
/tests/components/weatherkit/ @tjhorner
|
||||
/homeassistant/components/webdav/ @jpbede
|
||||
/tests/components/webdav/ @jpbede
|
||||
/homeassistant/components/webhook/ @home-assistant/core
|
||||
/tests/components/webhook/ @home-assistant/core
|
||||
/homeassistant/components/webmin/ @autinerd
|
||||
|
@@ -328,10 +328,10 @@ async def async_setup_hass(
|
||||
|
||||
block_async_io.enable()
|
||||
|
||||
if not (recovery_mode := runtime_config.recovery_mode):
|
||||
config_dict = None
|
||||
basic_setup_success = False
|
||||
|
||||
if not (recovery_mode := runtime_config.recovery_mode):
|
||||
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
try:
|
||||
@@ -355,12 +355,16 @@ async def async_setup_hass(
|
||||
hass = await create_hass()
|
||||
|
||||
elif not basic_setup_success:
|
||||
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
|
||||
_LOGGER.warning(
|
||||
"Unable to set up core integrations. Activating recovery mode"
|
||||
)
|
||||
recovery_mode = True
|
||||
await stop_hass(hass)
|
||||
hass = await create_hass()
|
||||
|
||||
elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
|
||||
elif any(
|
||||
domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS
|
||||
):
|
||||
_LOGGER.warning(
|
||||
"Detected that %s did not load. Activating recovery mode",
|
||||
",".join(CRITICAL_INTEGRATIONS),
|
||||
|
@@ -6,6 +6,7 @@
|
||||
"azure_devops",
|
||||
"azure_event_hub",
|
||||
"azure_service_bus",
|
||||
"azure_storage",
|
||||
"microsoft_face_detect",
|
||||
"microsoft_face_identify",
|
||||
"microsoft_face",
|
||||
|
@@ -7,6 +7,6 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["androidtvremote2"],
|
||||
"requirements": ["androidtvremote2==0.1.2"],
|
||||
"requirements": ["androidtvremote2==0.2.0"],
|
||||
"zeroconf": ["_androidtvremote2._tcp.local."]
|
||||
}
|
||||
|
82
homeassistant/components/azure_storage/__init__.py
Normal file
82
homeassistant/components/azure_storage/__init__.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""The Azure Storage integration."""
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
from azure.core.exceptions import (
|
||||
ClientAuthenticationError,
|
||||
HttpResponseError,
|
||||
ResourceNotFoundError,
|
||||
)
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
AioHttpTransport,
|
||||
) # need to import from private file, as it is not properly imported in the init
|
||||
from azure.storage.blob.aio import ContainerClient
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ACCOUNT_NAME,
|
||||
CONF_CONTAINER_NAME,
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
DATA_BACKUP_AGENT_LISTENERS,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
type AzureStorageConfigEntry = ConfigEntry[ContainerClient]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: AzureStorageConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Azure Storage integration."""
|
||||
# set increase aiohttp timeout for long running operations (up/download)
|
||||
session = async_create_clientsession(
|
||||
hass, timeout=ClientTimeout(connect=10, total=12 * 60 * 60)
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{entry.data[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=entry.data[CONF_CONTAINER_NAME],
|
||||
credential=entry.data[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=session),
|
||||
)
|
||||
|
||||
try:
|
||||
if not await container_client.exists():
|
||||
await container_client.create_container()
|
||||
except ResourceNotFoundError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="account_not_found",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except ClientAuthenticationError as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_auth",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
except HttpResponseError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={CONF_ACCOUNT_NAME: entry.data[CONF_ACCOUNT_NAME]},
|
||||
) from err
|
||||
|
||||
entry.runtime_data = container_client
|
||||
|
||||
def _async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(_async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: AzureStorageConfigEntry
|
||||
) -> bool:
|
||||
"""Unload an Azure Storage config entry."""
|
||||
return True
|
182
homeassistant/components/azure_storage/backup.py
Normal file
182
homeassistant/components/azure_storage/backup.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""Support for Azure Storage backup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from azure.core.exceptions import HttpResponseError
|
||||
from azure.storage.blob import BlobProperties
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
AgentBackup,
|
||||
BackupAgent,
|
||||
BackupAgentError,
|
||||
BackupNotFound,
|
||||
suggested_filename,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
|
||||
from . import AzureStorageConfigEntry
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
METADATA_VERSION = "1"
|
||||
|
||||
|
||||
async def async_get_backup_agents(
|
||||
hass: HomeAssistant,
|
||||
) -> list[BackupAgent]:
|
||||
"""Return a list of backup agents."""
|
||||
entries: list[AzureStorageConfigEntry] = hass.config_entries.async_loaded_entries(
|
||||
DOMAIN
|
||||
)
|
||||
return [AzureStorageBackupAgent(hass, entry) for entry in entries]
|
||||
|
||||
|
||||
@callback
|
||||
def async_register_backup_agents_listener(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
listener: Callable[[], None],
|
||||
**kwargs: Any,
|
||||
) -> Callable[[], None]:
|
||||
"""Register a listener to be called when agents are added or removed."""
|
||||
hass.data.setdefault(DATA_BACKUP_AGENT_LISTENERS, []).append(listener)
|
||||
|
||||
@callback
|
||||
def remove_listener() -> None:
|
||||
"""Remove the listener."""
|
||||
hass.data[DATA_BACKUP_AGENT_LISTENERS].remove(listener)
|
||||
if not hass.data[DATA_BACKUP_AGENT_LISTENERS]:
|
||||
hass.data.pop(DATA_BACKUP_AGENT_LISTENERS)
|
||||
|
||||
return remove_listener
|
||||
|
||||
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[AzureStorageBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
self: AzureStorageBackupAgent, *args: P.args, **kwargs: P.kwargs
|
||||
) -> _R:
|
||||
try:
|
||||
return await func(self, *args, **kwargs)
|
||||
except HttpResponseError as err:
|
||||
_LOGGER.debug(
|
||||
"Error during backup in %s: Status %s, message %s",
|
||||
func.__name__,
|
||||
err.status_code,
|
||||
err.message,
|
||||
exc_info=True,
|
||||
)
|
||||
raise BackupAgentError(
|
||||
f"Error during backup operation in {func.__name__}:"
|
||||
f" Status {err.status_code}, message: {err.message}"
|
||||
) from err
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class AzureStorageBackupAgent(BackupAgent):
|
||||
"""Azure storage backup agent."""
|
||||
|
||||
domain = DOMAIN
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: AzureStorageConfigEntry) -> None:
|
||||
"""Initialize the Azure storage backup agent."""
|
||||
super().__init__()
|
||||
self._client = entry.runtime_data
|
||||
self.name = entry.title
|
||||
self.unique_id = entry.entry_id
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_download_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AsyncIterator[bytes]:
|
||||
"""Download a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
raise BackupNotFound(f"Backup {backup_id} not found")
|
||||
download_stream = await self._client.download_blob(blob.name)
|
||||
return download_stream.chunks()
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_upload_backup(
|
||||
self,
|
||||
*,
|
||||
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
|
||||
backup: AgentBackup,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Upload a backup."""
|
||||
|
||||
metadata = {
|
||||
"metadata_version": METADATA_VERSION,
|
||||
"backup_id": backup.backup_id,
|
||||
"backup_metadata": json.dumps(backup.as_dict()),
|
||||
}
|
||||
|
||||
await self._client.upload_blob(
|
||||
name=suggested_filename(backup),
|
||||
metadata=metadata,
|
||||
data=await open_stream(),
|
||||
length=backup.size,
|
||||
)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_delete_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Delete a backup file."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
return
|
||||
await self._client.delete_blob(blob.name)
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_list_backups(self, **kwargs: Any) -> list[AgentBackup]:
|
||||
"""List backups."""
|
||||
backups: list[AgentBackup] = []
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
metadata = blob.metadata
|
||||
|
||||
if metadata.get("metadata_version") == METADATA_VERSION:
|
||||
backups.append(
|
||||
AgentBackup.from_dict(json.loads(metadata["backup_metadata"]))
|
||||
)
|
||||
|
||||
return backups
|
||||
|
||||
@handle_backup_errors
|
||||
async def async_get_backup(
|
||||
self,
|
||||
backup_id: str,
|
||||
**kwargs: Any,
|
||||
) -> AgentBackup | None:
|
||||
"""Return a backup."""
|
||||
blob = await self._find_blob_by_backup_id(backup_id)
|
||||
if blob is None:
|
||||
return None
|
||||
|
||||
return AgentBackup.from_dict(json.loads(blob.metadata["backup_metadata"]))
|
||||
|
||||
async def _find_blob_by_backup_id(self, backup_id: str) -> BlobProperties | None:
|
||||
"""Find a blob by backup id."""
|
||||
async for blob in self._client.list_blobs(include="metadata"):
|
||||
if (
|
||||
backup_id == blob.metadata.get("backup_id", "")
|
||||
and blob.metadata.get("metadata_version") == METADATA_VERSION
|
||||
):
|
||||
return blob
|
||||
return None
|
72
homeassistant/components/azure_storage/config_flow.py
Normal file
72
homeassistant/components/azure_storage/config_flow.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Config flow for Azure Storage integration."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from azure.core.exceptions import ClientAuthenticationError, ResourceNotFoundError
|
||||
from azure.core.pipeline.transport._aiohttp import (
|
||||
AioHttpTransport,
|
||||
) # need to import from private file, as it is not properly imported in the init
|
||||
from azure.storage.blob.aio import ContainerClient
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import (
|
||||
CONF_ACCOUNT_NAME,
|
||||
CONF_CONTAINER_NAME,
|
||||
CONF_STORAGE_ACCOUNT_KEY,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AzureStorageConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for azure storage."""
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""User step for Azure Storage."""
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(
|
||||
{CONF_ACCOUNT_NAME: user_input[CONF_ACCOUNT_NAME]}
|
||||
)
|
||||
container_client = ContainerClient(
|
||||
account_url=f"https://{user_input[CONF_ACCOUNT_NAME]}.blob.core.windows.net/",
|
||||
container_name=user_input[CONF_CONTAINER_NAME],
|
||||
credential=user_input[CONF_STORAGE_ACCOUNT_KEY],
|
||||
transport=AioHttpTransport(session=async_get_clientsession(self.hass)),
|
||||
)
|
||||
try:
|
||||
await container_client.exists()
|
||||
except ResourceNotFoundError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except ClientAuthenticationError:
|
||||
errors[CONF_STORAGE_ACCOUNT_KEY] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown exception occurred")
|
||||
errors["base"] = "unknown"
|
||||
if not errors:
|
||||
return self.async_create_entry(
|
||||
title=f"{user_input[CONF_ACCOUNT_NAME]}/{user_input[CONF_CONTAINER_NAME]}",
|
||||
data=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ACCOUNT_NAME): str,
|
||||
vol.Required(
|
||||
CONF_CONTAINER_NAME, default="home-assistant-backups"
|
||||
): str,
|
||||
vol.Required(CONF_STORAGE_ACCOUNT_KEY): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
)
|
16
homeassistant/components/azure_storage/const.py
Normal file
16
homeassistant/components/azure_storage/const.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Constants for the Azure Storage integration."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Final
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "azure_storage"
|
||||
|
||||
CONF_STORAGE_ACCOUNT_KEY: Final = "storage_account_key"
|
||||
CONF_ACCOUNT_NAME: Final = "account_name"
|
||||
CONF_CONTAINER_NAME: Final = "container_name"
|
||||
|
||||
DATA_BACKUP_AGENT_LISTENERS: HassKey[list[Callable[[], None]]] = HassKey(
|
||||
f"{DOMAIN}.backup_agent_listeners"
|
||||
)
|
12
homeassistant/components/azure_storage/manifest.json
Normal file
12
homeassistant/components/azure_storage/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "azure_storage",
|
||||
"name": "Azure Storage",
|
||||
"codeowners": ["@zweckj"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/azure_storage",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["azure-storage-blob"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["azure-storage-blob==12.24.0"]
|
||||
}
|
133
homeassistant/components/azure_storage/quality_scale.yaml
Normal file
133
homeassistant/components/azure_storage/quality_scale.yaml
Normal file
@@ -0,0 +1,133 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not register custom actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any custom actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities of this integration does not explicitly subscribe to events.
|
||||
entity-unique-id:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
has-entity-name:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup: done
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have any configuration parameters.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
parallel-updates:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have platforms.
|
||||
reauthentication-flow: todo
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
diagnostics:
|
||||
status: exempt
|
||||
comment: |
|
||||
There is no data to diagnose.
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service and does not support discovery.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not poll or push.
|
||||
docs-examples:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration only serves backup.
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is a cloud service.
|
||||
docs-supported-functions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not have entities.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration connects to a single service.
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: done
|
48
homeassistant/components/azure_storage/strings.json
Normal file
48
homeassistant/components/azure_storage/strings.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"config": {
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"storage_account_key": "Storage account key",
|
||||
"account_name": "Account name",
|
||||
"container_name": "Container name"
|
||||
},
|
||||
"data_description": {
|
||||
"storage_account_key": "Storage account access key used for authorization",
|
||||
"account_name": "Name of the storage account",
|
||||
"container_name": "Name of the storage container to be used (will be created if it does not exist)"
|
||||
},
|
||||
"description": "Set up an Azure (Blob) storage account to be used for backups.",
|
||||
"title": "Add Azure storage account"
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"container_not_found": {
|
||||
"title": "Storage container not found",
|
||||
"description": "The storage container {container_name} has not been found in the storage account. Please re-create it manually, then fix this issue."
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"account_not_found": {
|
||||
"message": "Storage account {account_name} not found"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "Can not connect to storage account {account_name}"
|
||||
},
|
||||
"invalid_auth": {
|
||||
"message": "Authentication failed for storage account {account_name}"
|
||||
},
|
||||
"container_not_found": {
|
||||
"message": "Storage container {container_name} not found"
|
||||
}
|
||||
}
|
||||
}
|
@@ -39,6 +39,7 @@ class StoredBackupConfig(TypedDict):
|
||||
"""Represent the stored backup config."""
|
||||
|
||||
agents: dict[str, StoredAgentConfig]
|
||||
automatic_backups_configured: bool
|
||||
create_backup: StoredCreateBackupConfig
|
||||
last_attempted_automatic_backup: str | None
|
||||
last_completed_automatic_backup: str | None
|
||||
@@ -51,6 +52,7 @@ class BackupConfigData:
|
||||
"""Represent loaded backup config data."""
|
||||
|
||||
agents: dict[str, AgentConfig]
|
||||
automatic_backups_configured: bool # only used by frontend
|
||||
create_backup: CreateBackupConfig
|
||||
last_attempted_automatic_backup: datetime | None = None
|
||||
last_completed_automatic_backup: datetime | None = None
|
||||
@@ -88,6 +90,7 @@ class BackupConfigData:
|
||||
agent_id: AgentConfig(protected=agent_data["protected"])
|
||||
for agent_id, agent_data in data["agents"].items()
|
||||
},
|
||||
automatic_backups_configured=data["automatic_backups_configured"],
|
||||
create_backup=CreateBackupConfig(
|
||||
agent_ids=data["create_backup"]["agent_ids"],
|
||||
include_addons=data["create_backup"]["include_addons"],
|
||||
@@ -127,6 +130,7 @@ class BackupConfigData:
|
||||
agents={
|
||||
agent_id: agent.to_dict() for agent_id, agent in self.agents.items()
|
||||
},
|
||||
automatic_backups_configured=self.automatic_backups_configured,
|
||||
create_backup=self.create_backup.to_dict(),
|
||||
last_attempted_automatic_backup=last_attempted,
|
||||
last_completed_automatic_backup=last_completed,
|
||||
@@ -142,6 +146,7 @@ class BackupConfig:
|
||||
"""Initialize backup config."""
|
||||
self.data = BackupConfigData(
|
||||
agents={},
|
||||
automatic_backups_configured=False,
|
||||
create_backup=CreateBackupConfig(),
|
||||
retention=RetentionConfig(),
|
||||
schedule=BackupSchedule(),
|
||||
@@ -159,6 +164,7 @@ class BackupConfig:
|
||||
self,
|
||||
*,
|
||||
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
|
||||
automatic_backups_configured: bool | UndefinedType = UNDEFINED,
|
||||
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
|
||||
retention: RetentionParametersDict | UndefinedType = UNDEFINED,
|
||||
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
|
||||
@@ -172,6 +178,8 @@ class BackupConfig:
|
||||
self.data.agents[agent_id] = replace(
|
||||
self.data.agents[agent_id], **agent_config
|
||||
)
|
||||
if automatic_backups_configured is not UNDEFINED:
|
||||
self.data.automatic_backups_configured = automatic_backups_configured
|
||||
if create_backup is not UNDEFINED:
|
||||
self.data.create_backup = replace(self.data.create_backup, **create_backup)
|
||||
if retention is not UNDEFINED:
|
||||
|
@@ -16,7 +16,7 @@ if TYPE_CHECKING:
|
||||
STORE_DELAY_SAVE = 30
|
||||
STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 4
|
||||
STORAGE_VERSION_MINOR = 5
|
||||
|
||||
|
||||
class StoredBackupData(TypedDict):
|
||||
@@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]):
|
||||
data["config"]["retention"]["copies"] = None
|
||||
if data["config"]["retention"]["days"] == 0:
|
||||
data["config"]["retention"]["days"] = None
|
||||
if old_minor_version < 5:
|
||||
# Version 1.5 adds automatic_backups_configured
|
||||
data["config"]["automatic_backups_configured"] = (
|
||||
data["config"]["create_backup"]["password"] is not None
|
||||
)
|
||||
|
||||
# Note: We allow reading data with major version 2.
|
||||
# Reject if major version is higher than 2.
|
||||
|
@@ -352,6 +352,7 @@ async def handle_config_info(
|
||||
{
|
||||
vol.Required("type"): "backup/config/update",
|
||||
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
|
||||
vol.Optional("automatic_backups_configured"): bool,
|
||||
vol.Optional("create_backup"): vol.Schema(
|
||||
{
|
||||
vol.Optional("agent_ids"): vol.All([str], vol.Unique()),
|
||||
|
@@ -21,6 +21,6 @@
|
||||
"bluetooth-auto-recovery==1.4.4",
|
||||
"bluetooth-data-tools==1.23.4",
|
||||
"dbus-fast==2.33.0",
|
||||
"habluetooth==3.22.1"
|
||||
"habluetooth==3.24.0"
|
||||
]
|
||||
}
|
||||
|
@@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner):
|
||||
"""Open connection to the router and get arp entries."""
|
||||
|
||||
try:
|
||||
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8")
|
||||
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8")
|
||||
cisco_ssh.login(
|
||||
self.host,
|
||||
self.username,
|
||||
|
@@ -30,10 +30,15 @@ async def async_setup_entry(
|
||||
async_add_entities(
|
||||
[
|
||||
DemoWaterHeater(
|
||||
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco"
|
||||
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
|
||||
),
|
||||
DemoWaterHeater(
|
||||
"Demo Water Heater Celsius", 45, UnitOfTemperature.CELSIUS, True, "eco"
|
||||
"Demo Water Heater Celsius",
|
||||
45,
|
||||
UnitOfTemperature.CELSIUS,
|
||||
True,
|
||||
"eco",
|
||||
1,
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -52,6 +57,7 @@ class DemoWaterHeater(WaterHeaterEntity):
|
||||
unit_of_measurement: str,
|
||||
away: bool,
|
||||
current_operation: str,
|
||||
target_temperature_step: float,
|
||||
) -> None:
|
||||
"""Initialize the water_heater device."""
|
||||
self._attr_name = name
|
||||
@@ -74,6 +80,7 @@ class DemoWaterHeater(WaterHeaterEntity):
|
||||
"gas",
|
||||
"off",
|
||||
]
|
||||
self._attr_target_temperature_step = target_temperature_step
|
||||
|
||||
def set_temperature(self, **kwargs: Any) -> None:
|
||||
"""Set new target temperatures."""
|
||||
|
@@ -14,8 +14,8 @@
|
||||
],
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"aiodhcpwatcher==1.1.0",
|
||||
"aiodiscover==2.6.0",
|
||||
"cached-ipaddress==0.8.0"
|
||||
"aiodhcpwatcher==1.1.1",
|
||||
"aiodiscover==2.6.1",
|
||||
"cached-ipaddress==0.8.1"
|
||||
]
|
||||
}
|
||||
|
@@ -25,6 +25,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_MODE,
|
||||
CONF_PASSWORD,
|
||||
CONF_SCAN_INTERVAL,
|
||||
CONF_USERNAME,
|
||||
@@ -40,11 +41,10 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import (
|
||||
ATTR_DURATION_DAYS,
|
||||
ATTR_DURATION_HOURS,
|
||||
ATTR_DURATION,
|
||||
ATTR_DURATION_UNTIL,
|
||||
ATTR_SYSTEM_MODE,
|
||||
ATTR_ZONE_TEMP,
|
||||
ATTR_PERIOD,
|
||||
ATTR_SETPOINT,
|
||||
CONF_LOCATION_IDX,
|
||||
DOMAIN,
|
||||
SCAN_INTERVAL_DEFAULT,
|
||||
@@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(ATTR_ZONE_TEMP): vol.All(
|
||||
vol.Required(ATTR_SETPOINT): vol.All(
|
||||
vol.Coerce(float), vol.Range(min=4.0, max=35.0)
|
||||
),
|
||||
vol.Optional(ATTR_DURATION_UNTIL): vol.All(
|
||||
@@ -222,7 +222,7 @@ def setup_service_functions(
|
||||
# Permanent-only modes will use this schema
|
||||
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]]
|
||||
if perm_modes: # any of: "Auto", "HeatingOff": permanent only
|
||||
schema = vol.Schema({vol.Required(ATTR_SYSTEM_MODE): vol.In(perm_modes)})
|
||||
schema = vol.Schema({vol.Required(ATTR_MODE): vol.In(perm_modes)})
|
||||
system_mode_schemas.append(schema)
|
||||
|
||||
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]]
|
||||
@@ -232,8 +232,8 @@ def setup_service_functions(
|
||||
if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
|
||||
vol.Optional(ATTR_DURATION_HOURS): vol.All(
|
||||
vol.Required(ATTR_MODE): vol.In(temp_modes),
|
||||
vol.Optional(ATTR_DURATION): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
|
||||
),
|
||||
@@ -246,8 +246,8 @@ def setup_service_functions(
|
||||
if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
|
||||
schema = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes),
|
||||
vol.Optional(ATTR_DURATION_DAYS): vol.All(
|
||||
vol.Required(ATTR_MODE): vol.In(temp_modes),
|
||||
vol.Optional(ATTR_PERIOD): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
|
||||
),
|
||||
|
@@ -29,7 +29,7 @@ from homeassistant.components.climate import (
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import PRECISION_TENTHS, UnitOfTemperature
|
||||
from homeassistant.const import ATTR_MODE, PRECISION_TENTHS, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -38,11 +38,10 @@ from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import EVOHOME_KEY
|
||||
from .const import (
|
||||
ATTR_DURATION_DAYS,
|
||||
ATTR_DURATION_HOURS,
|
||||
ATTR_DURATION,
|
||||
ATTR_DURATION_UNTIL,
|
||||
ATTR_SYSTEM_MODE,
|
||||
ATTR_ZONE_TEMP,
|
||||
ATTR_PERIOD,
|
||||
ATTR_SETPOINT,
|
||||
EvoService,
|
||||
)
|
||||
from .coordinator import EvoDataUpdateCoordinator
|
||||
@@ -180,7 +179,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
|
||||
return
|
||||
|
||||
# otherwise it is EvoService.SET_ZONE_OVERRIDE
|
||||
temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp)
|
||||
temperature = max(min(data[ATTR_SETPOINT], self.max_temp), self.min_temp)
|
||||
|
||||
if ATTR_DURATION_UNTIL in data:
|
||||
duration: timedelta = data[ATTR_DURATION_UNTIL]
|
||||
@@ -349,16 +348,16 @@ class EvoController(EvoClimateEntity):
|
||||
Data validation is not required, it will have been done upstream.
|
||||
"""
|
||||
if service == EvoService.SET_SYSTEM_MODE:
|
||||
mode = data[ATTR_SYSTEM_MODE]
|
||||
mode = data[ATTR_MODE]
|
||||
else: # otherwise it is EvoService.RESET_SYSTEM
|
||||
mode = EvoSystemMode.AUTO_WITH_RESET
|
||||
|
||||
if ATTR_DURATION_DAYS in data:
|
||||
if ATTR_PERIOD in data:
|
||||
until = dt_util.start_of_local_day()
|
||||
until += data[ATTR_DURATION_DAYS]
|
||||
until += data[ATTR_PERIOD]
|
||||
|
||||
elif ATTR_DURATION_HOURS in data:
|
||||
until = dt_util.now() + data[ATTR_DURATION_HOURS]
|
||||
elif ATTR_DURATION in data:
|
||||
until = dt_util.now() + data[ATTR_DURATION]
|
||||
|
||||
else:
|
||||
until = None
|
||||
|
@@ -18,11 +18,10 @@ USER_DATA: Final = "user_data"
|
||||
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
|
||||
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
|
||||
|
||||
ATTR_SYSTEM_MODE: Final = "mode"
|
||||
ATTR_DURATION_DAYS: Final = "period"
|
||||
ATTR_DURATION_HOURS: Final = "duration"
|
||||
ATTR_PERIOD: Final = "period" # number of days
|
||||
ATTR_DURATION: Final = "duration" # number of minutes, <24h
|
||||
|
||||
ATTR_ZONE_TEMP: Final = "setpoint"
|
||||
ATTR_SETPOINT: Final = "setpoint"
|
||||
ATTR_DURATION_UNTIL: Final = "duration"
|
||||
|
||||
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyfritzhome"],
|
||||
"requirements": ["pyfritzhome==0.6.15"],
|
||||
"requirements": ["pyfritzhome==0.6.17"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:fritzbox:1"
|
||||
|
@@ -21,5 +21,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20250214.0"]
|
||||
"requirements": ["home-assistant-frontend==20250221.0"]
|
||||
}
|
||||
|
@@ -7,7 +7,7 @@ from collections.abc import Callable
|
||||
from google_drive_api.exceptions import GoogleDriveApiError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import instance_id
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
|
||||
except GoogleDriveApiError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
@@ -58,15 +62,4 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleDriveConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
return True
|
||||
|
||||
|
||||
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
|
||||
hass.loop.call_soon(_async_notify_backup_listeners, hass)
|
||||
|
@@ -2,14 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
|
||||
from google.ai import generativelanguage_v1beta
|
||||
from google.api_core.client_options import ClientOptions
|
||||
from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPIError
|
||||
import google.generativeai as genai
|
||||
import google.generativeai.types as genai_types
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -27,59 +24,86 @@ from homeassistant.exceptions import (
|
||||
HomeAssistantError,
|
||||
)
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, RECOMMENDED_CHAT_MODEL
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_PROMPT,
|
||||
DOMAIN,
|
||||
RECOMMENDED_CHAT_MODEL,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
|
||||
SERVICE_GENERATE_CONTENT = "generate_content"
|
||||
CONF_IMAGE_FILENAME = "image_filename"
|
||||
CONF_FILENAMES = "filenames"
|
||||
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
PLATFORMS = (Platform.CONVERSATION,)
|
||||
|
||||
type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Google Generative AI Conversation."""
|
||||
|
||||
async def generate_content(call: ServiceCall) -> ServiceResponse:
|
||||
"""Generate content from text and optionally images."""
|
||||
|
||||
if call.data[CONF_IMAGE_FILENAME]:
|
||||
# Deprecated in 2025.3, to remove in 2025.9
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_image_filename_parameter",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_image_filename_parameter",
|
||||
)
|
||||
|
||||
prompt_parts = [call.data[CONF_PROMPT]]
|
||||
|
||||
config_entry: GoogleGenerativeAIConfigEntry = hass.config_entries.async_entries(
|
||||
DOMAIN
|
||||
)[0]
|
||||
|
||||
client = config_entry.runtime_data
|
||||
|
||||
def append_files_to_prompt():
|
||||
image_filenames = call.data[CONF_IMAGE_FILENAME]
|
||||
for image_filename in image_filenames:
|
||||
if not hass.config.is_allowed_path(image_filename):
|
||||
filenames = call.data[CONF_FILENAMES]
|
||||
for filename in set(image_filenames + filenames):
|
||||
if not hass.config.is_allowed_path(filename):
|
||||
raise HomeAssistantError(
|
||||
f"Cannot read `{image_filename}`, no access to path; "
|
||||
f"Cannot read `{filename}`, no access to path; "
|
||||
"`allowlist_external_dirs` may need to be adjusted in "
|
||||
"`configuration.yaml`"
|
||||
)
|
||||
if not Path(image_filename).exists():
|
||||
raise HomeAssistantError(f"`{image_filename}` does not exist")
|
||||
mime_type, _ = mimetypes.guess_type(image_filename)
|
||||
if mime_type is None or not mime_type.startswith("image"):
|
||||
raise HomeAssistantError(f"`{image_filename}` is not an image")
|
||||
prompt_parts.append(
|
||||
{
|
||||
"mime_type": mime_type,
|
||||
"data": await hass.async_add_executor_job(
|
||||
Path(image_filename).read_bytes
|
||||
),
|
||||
}
|
||||
)
|
||||
if not Path(filename).exists():
|
||||
raise HomeAssistantError(f"`{filename}` does not exist")
|
||||
prompt_parts.append(client.files.upload(file=filename))
|
||||
|
||||
model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL)
|
||||
await hass.async_add_executor_job(append_files_to_prompt)
|
||||
|
||||
try:
|
||||
response = await model.generate_content_async(prompt_parts)
|
||||
response = await client.aio.models.generate_content(
|
||||
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
|
||||
)
|
||||
except (
|
||||
GoogleAPIError,
|
||||
APIError,
|
||||
ValueError,
|
||||
genai_types.BlockedPromptException,
|
||||
genai_types.StopCandidateException,
|
||||
) as err:
|
||||
raise HomeAssistantError(f"Error generating content: {err}") from err
|
||||
|
||||
if not response.parts:
|
||||
raise HomeAssistantError("Error generating content")
|
||||
if response.prompt_feedback:
|
||||
raise HomeAssistantError(
|
||||
f"Error generating content due to content violations, reason: {response.prompt_feedback.block_reason_message}"
|
||||
)
|
||||
|
||||
if not response.candidates[0].content.parts:
|
||||
raise HomeAssistantError("Unknown error generating content")
|
||||
|
||||
return {"text": response.text}
|
||||
|
||||
@@ -93,6 +117,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
vol.Optional(CONF_FILENAMES, default=[]): vol.All(
|
||||
cv.ensure_list, [cv.string]
|
||||
),
|
||||
}
|
||||
),
|
||||
supports_response=SupportsResponse.ONLY,
|
||||
@@ -100,30 +127,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Google Generative AI Conversation from a config entry."""
|
||||
genai.configure(api_key=entry.data[CONF_API_KEY])
|
||||
|
||||
try:
|
||||
client = generativelanguage_v1beta.ModelServiceAsyncClient(
|
||||
client_options=ClientOptions(api_key=entry.data[CONF_API_KEY])
|
||||
client = genai.Client(api_key=entry.data[CONF_API_KEY])
|
||||
await client.aio.models.get(
|
||||
model=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
|
||||
config={"http_options": {"timeout": TIMEOUT_MILLIS}},
|
||||
)
|
||||
await client.get_model(
|
||||
name=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), timeout=5.0
|
||||
)
|
||||
except (GoogleAPIError, ValueError) as err:
|
||||
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
|
||||
raise ConfigEntryAuthFailed(err) from err
|
||||
if isinstance(err, DeadlineExceeded):
|
||||
except (APIError, Timeout) as err:
|
||||
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
|
||||
raise ConfigEntryAuthFailed(err.message) from err
|
||||
if isinstance(err, Timeout):
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
raise ConfigEntryError(err) from err
|
||||
else:
|
||||
entry.runtime_data = client
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
|
||||
) -> bool:
|
||||
"""Unload GoogleGenerativeAI."""
|
||||
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
return False
|
||||
|
@@ -3,15 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from functools import partial
|
||||
import logging
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from google.ai import generativelanguage_v1beta
|
||||
from google.api_core.client_options import ClientOptions
|
||||
from google.api_core.exceptions import ClientError, GoogleAPIError
|
||||
import google.generativeai as genai
|
||||
from google import genai # type: ignore[attr-defined]
|
||||
from google.genai.errors import APIError, ClientError
|
||||
from requests.exceptions import Timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
@@ -53,6 +51,7 @@ from .const import (
|
||||
RECOMMENDED_TEMPERATURE,
|
||||
RECOMMENDED_TOP_K,
|
||||
RECOMMENDED_TOP_P,
|
||||
TIMEOUT_MILLIS,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -70,15 +69,20 @@ RECOMMENDED_OPTIONS = {
|
||||
}
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
async def validate_input(data: dict[str, Any]) -> None:
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
client = generativelanguage_v1beta.ModelServiceAsyncClient(
|
||||
client_options=ClientOptions(api_key=data[CONF_API_KEY])
|
||||
client = genai.Client(api_key=data[CONF_API_KEY])
|
||||
await client.aio.models.list(
|
||||
config={
|
||||
"http_options": {
|
||||
"timeout": TIMEOUT_MILLIS,
|
||||
},
|
||||
"query_base": True,
|
||||
}
|
||||
)
|
||||
await client.list_models(timeout=5.0)
|
||||
|
||||
|
||||
class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -93,9 +97,9 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except GoogleAPIError as err:
|
||||
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
|
||||
await validate_input(user_input)
|
||||
except (APIError, Timeout) as err:
|
||||
if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
errors["base"] = "cannot_connect"
|
||||
@@ -166,6 +170,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
self.last_rendered_recommended = config_entry.options.get(
|
||||
CONF_RECOMMENDED, False
|
||||
)
|
||||
self._genai_client = config_entry.runtime_data
|
||||
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -188,7 +193,9 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
CONF_LLM_HASS_API: user_input[CONF_LLM_HASS_API],
|
||||
}
|
||||
|
||||
schema = await google_generative_ai_config_option_schema(self.hass, options)
|
||||
schema = await google_generative_ai_config_option_schema(
|
||||
self.hass, options, self._genai_client
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="init",
|
||||
data_schema=vol.Schema(schema),
|
||||
@@ -198,6 +205,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
|
||||
async def google_generative_ai_config_option_schema(
|
||||
hass: HomeAssistant,
|
||||
options: dict[str, Any] | MappingProxyType[str, Any],
|
||||
genai_client: genai.Client,
|
||||
) -> dict:
|
||||
"""Return a schema for Google Generative AI completion options."""
|
||||
hass_apis: list[SelectOptionDict] = [
|
||||
@@ -236,18 +244,21 @@ async def google_generative_ai_config_option_schema(
|
||||
if options.get(CONF_RECOMMENDED):
|
||||
return schema
|
||||
|
||||
api_models = await hass.async_add_executor_job(partial(genai.list_models))
|
||||
|
||||
api_models_pager = await genai_client.aio.models.list(config={"query_base": True})
|
||||
api_models = [api_model async for api_model in api_models_pager]
|
||||
models = [
|
||||
SelectOptionDict(
|
||||
label=api_model.display_name,
|
||||
value=api_model.name,
|
||||
)
|
||||
for api_model in sorted(api_models, key=lambda x: x.display_name)
|
||||
for api_model in sorted(api_models, key=lambda x: x.display_name or "")
|
||||
if (
|
||||
api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro
|
||||
and api_model.display_name
|
||||
and api_model.name
|
||||
and api_model.supported_actions
|
||||
and "vision" not in api_model.name
|
||||
and "generateContent" in api_model.supported_generation_methods
|
||||
and "generateContent" in api_model.supported_actions
|
||||
)
|
||||
]
|
||||
|
||||
|
@@ -22,3 +22,5 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE"
|
||||
|
||||
TIMEOUT_MILLIS = 10000
|
||||
|
@@ -6,11 +6,18 @@ import codecs
|
||||
from collections.abc import Callable
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
from google.api_core.exceptions import GoogleAPIError
|
||||
import google.generativeai as genai
|
||||
from google.generativeai import protos
|
||||
import google.generativeai.types as genai_types
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
from google.genai.errors import APIError
|
||||
from google.genai.types import (
|
||||
AutomaticFunctionCallingConfig,
|
||||
Content,
|
||||
FunctionDeclaration,
|
||||
GenerateContentConfig,
|
||||
HarmCategory,
|
||||
Part,
|
||||
SafetySetting,
|
||||
Schema,
|
||||
Tool,
|
||||
)
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
from homeassistant.components import assist_pipeline, conversation
|
||||
@@ -57,21 +64,40 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
SUPPORTED_SCHEMA_KEYS = {
|
||||
"type",
|
||||
"format",
|
||||
"description",
|
||||
"min_items",
|
||||
"example",
|
||||
"property_ordering",
|
||||
"pattern",
|
||||
"minimum",
|
||||
"default",
|
||||
"any_of",
|
||||
"max_length",
|
||||
"title",
|
||||
"min_properties",
|
||||
"min_length",
|
||||
"max_items",
|
||||
"maximum",
|
||||
"nullable",
|
||||
"max_properties",
|
||||
"type",
|
||||
"description",
|
||||
"enum",
|
||||
"format",
|
||||
"items",
|
||||
"properties",
|
||||
"required",
|
||||
}
|
||||
|
||||
|
||||
def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Format the schema to protobuf."""
|
||||
if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")):
|
||||
for subschema in subschemas: # Gemini API does not support anyOf and allOf keys
|
||||
def _camel_to_snake(name: str) -> str:
|
||||
"""Convert camel case to snake case."""
|
||||
return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
|
||||
|
||||
|
||||
def _format_schema(schema: dict[str, Any]) -> Schema:
|
||||
"""Format the schema to be compatible with Gemini API."""
|
||||
if subschemas := schema.get("allOf"):
|
||||
for subschema in subschemas: # Gemini API does not support allOf keys
|
||||
if "type" in subschema: # Fallback to first subschema with 'type' field
|
||||
return _format_schema(subschema)
|
||||
return _format_schema(
|
||||
@@ -80,42 +106,38 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
result = {}
|
||||
for key, val in schema.items():
|
||||
key = _camel_to_snake(key)
|
||||
if key not in SUPPORTED_SCHEMA_KEYS:
|
||||
continue
|
||||
if key == "any_of":
|
||||
val = [_format_schema(subschema) for subschema in val]
|
||||
if key == "type":
|
||||
key = "type_"
|
||||
val = val.upper()
|
||||
elif key == "format":
|
||||
if schema.get("type") == "string" and val != "enum":
|
||||
continue
|
||||
if schema.get("type") not in ("number", "integer", "string"):
|
||||
continue
|
||||
key = "format_"
|
||||
elif key == "items":
|
||||
if key == "items":
|
||||
val = _format_schema(val)
|
||||
elif key == "properties":
|
||||
val = {k: _format_schema(v) for k, v in val.items()}
|
||||
result[key] = val
|
||||
|
||||
if result.get("enum") and result.get("type_") != "STRING":
|
||||
if result.get("enum") and result.get("type") != "STRING":
|
||||
# enum is only allowed for STRING type. This is safe as long as the schema
|
||||
# contains vol.Coerce for the respective type, for example:
|
||||
# vol.All(vol.Coerce(int), vol.In([1, 2, 3]))
|
||||
result["type_"] = "STRING"
|
||||
result["type"] = "STRING"
|
||||
result["enum"] = [str(item) for item in result["enum"]]
|
||||
|
||||
if result.get("type_") == "OBJECT" and not result.get("properties"):
|
||||
if result.get("type") == "OBJECT" and not result.get("properties"):
|
||||
# An object with undefined properties is not supported by Gemini API.
|
||||
# Fallback to JSON string. This will probably fail for most tools that want it,
|
||||
# but we don't have a better fallback strategy so far.
|
||||
result["properties"] = {"json": {"type_": "STRING"}}
|
||||
result["properties"] = {"json": {"type": "STRING"}}
|
||||
result["required"] = []
|
||||
return result
|
||||
return cast(Schema, result)
|
||||
|
||||
|
||||
def _format_tool(
|
||||
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
|
||||
) -> dict[str, Any]:
|
||||
) -> Tool:
|
||||
"""Format tool specification."""
|
||||
|
||||
if tool.parameters.schema:
|
||||
@@ -125,16 +147,14 @@ def _format_tool(
|
||||
else:
|
||||
parameters = None
|
||||
|
||||
return protos.Tool(
|
||||
{
|
||||
"function_declarations": [
|
||||
{
|
||||
"name": tool.name,
|
||||
"description": tool.description,
|
||||
"parameters": parameters,
|
||||
}
|
||||
return Tool(
|
||||
function_declarations=[
|
||||
FunctionDeclaration(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
parameters=parameters,
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -151,15 +171,13 @@ def _escape_decode(value: Any) -> Any:
|
||||
|
||||
def _create_google_tool_response_content(
|
||||
content: list[conversation.ToolResultContent],
|
||||
) -> protos.Content:
|
||||
) -> Content:
|
||||
"""Create a Google tool response content."""
|
||||
return protos.Content(
|
||||
return Content(
|
||||
parts=[
|
||||
protos.Part(
|
||||
function_response=protos.FunctionResponse(
|
||||
Part.from_function_response(
|
||||
name=tool_result.tool_name, response=tool_result.tool_result
|
||||
)
|
||||
)
|
||||
for tool_result in content
|
||||
]
|
||||
)
|
||||
@@ -169,33 +187,36 @@ def _convert_content(
|
||||
content: conversation.UserContent
|
||||
| conversation.AssistantContent
|
||||
| conversation.SystemContent,
|
||||
) -> genai_types.ContentDict:
|
||||
) -> Content:
|
||||
"""Convert HA content to Google content."""
|
||||
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
|
||||
role = "model" if content.role == "assistant" else content.role
|
||||
return {"role": role, "parts": content.content}
|
||||
return Content(
|
||||
role=role,
|
||||
parts=[
|
||||
Part.from_text(text=content.content if content.content else ""),
|
||||
],
|
||||
)
|
||||
|
||||
# Handle the Assistant content with tool calls.
|
||||
assert type(content) is conversation.AssistantContent
|
||||
parts = []
|
||||
parts: list[Part] = []
|
||||
|
||||
if content.content:
|
||||
parts.append(protos.Part(text=content.content))
|
||||
parts.append(Part.from_text(text=content.content))
|
||||
|
||||
if content.tool_calls:
|
||||
parts.extend(
|
||||
[
|
||||
protos.Part(
|
||||
function_call=protos.FunctionCall(
|
||||
Part.from_function_call(
|
||||
name=tool_call.tool_name,
|
||||
args=_escape_decode(tool_call.tool_args),
|
||||
)
|
||||
)
|
||||
for tool_call in content.tool_calls
|
||||
]
|
||||
)
|
||||
|
||||
return protos.Content({"role": "model", "parts": parts})
|
||||
return Content(role="model", parts=parts)
|
||||
|
||||
|
||||
class GoogleGenerativeAIConversationEntity(
|
||||
@@ -209,6 +230,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the agent."""
|
||||
self.entry = entry
|
||||
self._genai_client = entry.runtime_data
|
||||
self._attr_unique_id = entry.entry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
@@ -273,7 +295,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
except conversation.ConverseError as err:
|
||||
return err.as_conversation_result()
|
||||
|
||||
tools: list[dict[str, Any]] | None = None
|
||||
tools: list[Tool | Callable[..., Any]] | None = None
|
||||
if chat_log.llm_api:
|
||||
tools = [
|
||||
_format_tool(tool, chat_log.llm_api.custom_serializer)
|
||||
@@ -288,13 +310,22 @@ class GoogleGenerativeAIConversationEntity(
|
||||
"gemini-1.0" not in model_name and "gemini-pro" not in model_name
|
||||
)
|
||||
|
||||
prompt = chat_log.content[0].content # type: ignore[union-attr]
|
||||
messages: list[genai_types.ContentDict] = []
|
||||
prompt_content = cast(
|
||||
conversation.SystemContent,
|
||||
chat_log.content[0],
|
||||
)
|
||||
|
||||
if prompt_content.content:
|
||||
prompt = prompt_content.content
|
||||
else:
|
||||
raise HomeAssistantError("Invalid prompt content")
|
||||
|
||||
messages: list[Content] = []
|
||||
|
||||
# Google groups tool results, we do not. Group them before sending.
|
||||
tool_results: list[conversation.ToolResultContent] = []
|
||||
|
||||
for chat_content in chat_log.content[1:]:
|
||||
for chat_content in chat_log.content[1:-1]:
|
||||
if chat_content.role == "tool_result":
|
||||
# mypy doesn't like picking a type based on checking shared property 'role'
|
||||
tool_results.append(cast(conversation.ToolResultContent, chat_content))
|
||||
@@ -317,85 +348,93 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
if tool_results:
|
||||
messages.append(_create_google_tool_response_content(tool_results))
|
||||
|
||||
model = genai.GenerativeModel(
|
||||
model_name=model_name,
|
||||
generation_config={
|
||||
"temperature": self.entry.options.get(
|
||||
generateContentConfig = GenerateContentConfig(
|
||||
temperature=self.entry.options.get(
|
||||
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE
|
||||
),
|
||||
"top_p": self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
"top_k": self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
||||
"max_output_tokens": self.entry.options.get(
|
||||
top_k=self.entry.options.get(CONF_TOP_K, RECOMMENDED_TOP_K),
|
||||
top_p=self.entry.options.get(CONF_TOP_P, RECOMMENDED_TOP_P),
|
||||
max_output_tokens=self.entry.options.get(
|
||||
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
|
||||
),
|
||||
},
|
||||
safety_settings={
|
||||
"HARASSMENT": self.entry.options.get(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
"HATE": self.entry.options.get(
|
||||
safety_settings=[
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
"SEXUAL": self.entry.options.get(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
"DANGEROUS": self.entry.options.get(
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_HARASSMENT,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_HARASSMENT_BLOCK_THRESHOLD,
|
||||
RECOMMENDED_HARM_BLOCK_THRESHOLD,
|
||||
),
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_DANGEROUS_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
},
|
||||
),
|
||||
SafetySetting(
|
||||
category=HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT,
|
||||
threshold=self.entry.options.get(
|
||||
CONF_SEXUAL_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD
|
||||
),
|
||||
),
|
||||
],
|
||||
tools=tools or None,
|
||||
system_instruction=prompt if supports_system_instruction else None,
|
||||
automatic_function_calling=AutomaticFunctionCallingConfig(
|
||||
disable=True, maximum_remote_calls=None
|
||||
),
|
||||
)
|
||||
|
||||
if not supports_system_instruction:
|
||||
messages = [
|
||||
{"role": "user", "parts": prompt},
|
||||
{"role": "model", "parts": "Ok"},
|
||||
Content(role="user", parts=[Part.from_text(text=prompt)]),
|
||||
Content(role="model", parts=[Part.from_text(text="Ok")]),
|
||||
*messages,
|
||||
]
|
||||
|
||||
chat = model.start_chat(history=messages)
|
||||
chat_request = user_input.text
|
||||
chat = self._genai_client.aio.chats.create(
|
||||
model=model_name, history=messages, config=generateContentConfig
|
||||
)
|
||||
chat_request: str | Content = user_input.text
|
||||
# To prevent infinite loops, we limit the number of iterations
|
||||
for _iteration in range(MAX_TOOL_ITERATIONS):
|
||||
try:
|
||||
chat_response = await chat.send_message_async(chat_request)
|
||||
except (
|
||||
GoogleAPIError,
|
||||
ValueError,
|
||||
genai_types.BlockedPromptException,
|
||||
genai_types.StopCandidateException,
|
||||
) as err:
|
||||
LOGGER.error("Error sending message: %s %s", type(err), err)
|
||||
chat_response = await chat.send_message(message=chat_request)
|
||||
|
||||
if isinstance(
|
||||
err, genai_types.StopCandidateException
|
||||
) and "finish_reason: SAFETY\n" in str(err):
|
||||
error = "The message got blocked by your safety settings"
|
||||
else:
|
||||
error = (
|
||||
f"Sorry, I had a problem talking to Google Generative AI: {err}"
|
||||
if chat_response.prompt_feedback:
|
||||
raise HomeAssistantError(
|
||||
f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
|
||||
)
|
||||
|
||||
except (
|
||||
APIError,
|
||||
ValueError,
|
||||
) as err:
|
||||
LOGGER.error("Error sending message: %s %s", type(err), err)
|
||||
error = f"Sorry, I had a problem talking to Google Generative AI: {err}"
|
||||
raise HomeAssistantError(error) from err
|
||||
|
||||
LOGGER.debug("Response: %s", chat_response.parts)
|
||||
if not chat_response.parts:
|
||||
response_parts = chat_response.candidates[0].content.parts
|
||||
if not response_parts:
|
||||
raise HomeAssistantError(
|
||||
"Sorry, I had a problem getting a response from Google Generative AI."
|
||||
)
|
||||
content = " ".join(
|
||||
[part.text.strip() for part in chat_response.parts if part.text]
|
||||
[part.text.strip() for part in response_parts if part.text]
|
||||
)
|
||||
|
||||
tool_calls = []
|
||||
for part in chat_response.parts:
|
||||
for part in response_parts:
|
||||
if not part.function_call:
|
||||
continue
|
||||
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001
|
||||
tool_name = tool_call["name"]
|
||||
tool_args = _escape_decode(tool_call["args"])
|
||||
tool_call = part.function_call
|
||||
tool_name = tool_call.name
|
||||
tool_args = _escape_decode(tool_call.args)
|
||||
tool_calls.append(
|
||||
llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
|
||||
)
|
||||
@@ -418,7 +457,7 @@ class GoogleGenerativeAIConversationEntity(
|
||||
|
||||
response = intent.IntentResponse(language=user_input.language)
|
||||
response.async_set_speech(
|
||||
" ".join([part.text.strip() for part in chat_response.parts if part.text])
|
||||
" ".join([part.text.strip() for part in response_parts if part.text])
|
||||
)
|
||||
return conversation.ConversationResult(
|
||||
response=response, conversation_id=chat_log.conversation_id
|
||||
|
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["google-generativeai==0.8.2"]
|
||||
"requirements": ["google-genai==1.1.0"]
|
||||
}
|
||||
|
@@ -9,3 +9,8 @@ generate_content:
|
||||
required: false
|
||||
selector:
|
||||
object:
|
||||
filenames:
|
||||
required: false
|
||||
selector:
|
||||
text:
|
||||
multiple: true
|
||||
|
@@ -56,10 +56,21 @@
|
||||
},
|
||||
"image_filename": {
|
||||
"name": "Image filename",
|
||||
"description": "Images",
|
||||
"description": "Deprecated. Use filenames instead.",
|
||||
"example": "/config/www/image.jpg"
|
||||
},
|
||||
"filenames": {
|
||||
"name": "Attachment filenames",
|
||||
"description": "Attachments to add to the prompt (images, PDFs, etc)",
|
||||
"example": "/config/www/image.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_image_filename_parameter": {
|
||||
"title": "Deprecated 'image_filename' parameter",
|
||||
"description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
"""Move an item in the To-do list."""
|
||||
if TYPE_CHECKING:
|
||||
assert self.todo_items
|
||||
tasks_order = (
|
||||
self.coordinator.data.user.tasksOrder.todos
|
||||
if self.entity_description.key is HabiticaTodoList.TODOS
|
||||
else self.coordinator.data.user.tasksOrder.dailys
|
||||
)
|
||||
|
||||
if previous_uid:
|
||||
pos = self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == previous_uid)
|
||||
)
|
||||
if pos < self.todo_items.index(
|
||||
next(item for item in self.todo_items if item.uid == uid)
|
||||
):
|
||||
pos = tasks_order.index(UUID(previous_uid))
|
||||
if pos < tasks_order.index(UUID(uid)):
|
||||
pos += 1
|
||||
|
||||
else:
|
||||
pos = 0
|
||||
|
||||
try:
|
||||
tasks_order[:] = (
|
||||
await self.coordinator.habitica.reorder_task(UUID(uid), pos)
|
||||
).data
|
||||
except TooManyRequestsError as e:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
|
||||
translation_key=f"move_{self.entity_description.key}_item_failed",
|
||||
translation_placeholders={"pos": str(pos)},
|
||||
) from e
|
||||
else:
|
||||
# move tasks in the coordinator until we have fresh data
|
||||
tasks = self.coordinator.data.tasks
|
||||
new_pos = (
|
||||
tasks.index(
|
||||
next(task for task in tasks if task.id == UUID(previous_uid))
|
||||
)
|
||||
+ 1
|
||||
if previous_uid
|
||||
else 0
|
||||
)
|
||||
old_pos = tasks.index(next(task for task in tasks if task.id == UUID(uid)))
|
||||
tasks.insert(new_pos, tasks.pop(old_pos))
|
||||
await self.coordinator.async_request_refresh()
|
||||
|
||||
async def async_update_todo_item(self, item: TodoItem) -> None:
|
||||
"""Update a Habitica todo."""
|
||||
@@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
def todo_items(self) -> list[TodoItem]:
|
||||
"""Return the todo items."""
|
||||
|
||||
return [
|
||||
tasks = [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
@@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
|
||||
if task.Type is TaskType.TODO
|
||||
),
|
||||
]
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.todos)
|
||||
else tasks_order.index(uid)
|
||||
),
|
||||
)
|
||||
|
||||
async def async_create_todo_item(self, item: TodoItem) -> None:
|
||||
"""Create a Habitica todo."""
|
||||
@@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
if TYPE_CHECKING:
|
||||
assert self.coordinator.data.user.lastCron
|
||||
|
||||
return [
|
||||
tasks = [
|
||||
*(
|
||||
TodoItem(
|
||||
uid=str(task.id),
|
||||
@@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
|
||||
if task.Type is TaskType.DAILY
|
||||
)
|
||||
]
|
||||
return sorted(
|
||||
tasks,
|
||||
key=lambda task: (
|
||||
float("inf")
|
||||
if (uid := UUID(task.uid))
|
||||
not in (tasks_order := self.coordinator.data.user.tasksOrder.dailys)
|
||||
else tasks_order.index(uid)
|
||||
),
|
||||
)
|
||||
|
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pyheos"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["pyheos==1.0.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.66", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.67", "babel==2.15.0"]
|
||||
}
|
||||
|
@@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.BUTTON,
|
||||
Platform.LIGHT,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
@@ -212,7 +213,11 @@ async def _get_client_and_ha_id(
|
||||
break
|
||||
if entry is None:
|
||||
raise ServiceValidationError(
|
||||
"Home Connect config entry not found for that device id"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={
|
||||
"device_id": device_id,
|
||||
},
|
||||
)
|
||||
|
||||
ha_id = next(
|
||||
@@ -404,6 +409,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
||||
"""Execute calls to services executing a command."""
|
||||
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
|
||||
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"deprecated_command_actions",
|
||||
breaks_in_ha_version="2025.9.0",
|
||||
is_fixable=True,
|
||||
is_persistent=True,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="deprecated_command_actions",
|
||||
)
|
||||
|
||||
try:
|
||||
await client.put_command(ha_id, command_key=command_key, value=True)
|
||||
except HomeConnectError as err:
|
||||
@@ -609,6 +625,7 @@ async def async_unload_entry(
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
|
||||
async_delete_issue(hass, DOMAIN, "deprecated_command_actions")
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
|
160
homeassistant/components/home_connect/button.py
Normal file
160
homeassistant/components/home_connect/button.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Provides button entities for Home Connect."""
|
||||
|
||||
from aiohomeconnect.model import CommandKey, EventKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import APPLIANCES_WITH_PROGRAMS, DOMAIN
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
|
||||
class HomeConnectCommandButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes Home Connect button entity."""
|
||||
|
||||
key: CommandKey
|
||||
|
||||
|
||||
COMMAND_BUTTONS = (
|
||||
HomeConnectCommandButtonEntityDescription(
|
||||
key=CommandKey.BSH_COMMON_OPEN_DOOR,
|
||||
translation_key="open_door",
|
||||
),
|
||||
HomeConnectCommandButtonEntityDescription(
|
||||
key=CommandKey.BSH_COMMON_PARTLY_OPEN_DOOR,
|
||||
translation_key="partly_open_door",
|
||||
),
|
||||
HomeConnectCommandButtonEntityDescription(
|
||||
key=CommandKey.BSH_COMMON_PAUSE_PROGRAM,
|
||||
translation_key="pause_program",
|
||||
),
|
||||
HomeConnectCommandButtonEntityDescription(
|
||||
key=CommandKey.BSH_COMMON_RESUME_PROGRAM,
|
||||
translation_key="resume_program",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
entities: list[HomeConnectEntity] = []
|
||||
entities.extend(
|
||||
HomeConnectCommandButtonEntity(entry.runtime_data, appliance, description)
|
||||
for description in COMMAND_BUTTONS
|
||||
if description.key in appliance.commands
|
||||
)
|
||||
if appliance.info.type in APPLIANCES_WITH_PROGRAMS:
|
||||
entities.append(
|
||||
HomeConnectStopProgramButtonEntity(entry.runtime_data, appliance)
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Home Connect button entities."""
|
||||
setup_home_connect_entry(
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectButtonEntity(HomeConnectEntity, ButtonEntity):
|
||||
"""Describes Home Connect button entity."""
|
||||
|
||||
entity_description: ButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
desc: ButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
# The entity is subscribed to the appliance connected event,
|
||||
# but it will receive also the disconnected event
|
||||
ButtonEntityDescription(
|
||||
key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
|
||||
),
|
||||
)
|
||||
self.entity_description = desc
|
||||
self.appliance = appliance
|
||||
self.unique_id = f"{appliance.info.ha_id}-{desc.key}"
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
|
||||
|
||||
class HomeConnectCommandButtonEntity(HomeConnectButtonEntity):
|
||||
"""Button entity for Home Connect commands."""
|
||||
|
||||
entity_description: HomeConnectCommandButtonEntityDescription
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.coordinator.client.put_command(
|
||||
self.appliance.info.ha_id,
|
||||
command_key=self.entity_description.key,
|
||||
value=True,
|
||||
)
|
||||
except HomeConnectError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="execute_command",
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(error),
|
||||
"command": self.entity_description.key,
|
||||
},
|
||||
) from error
|
||||
|
||||
|
||||
class HomeConnectStopProgramButtonEntity(HomeConnectButtonEntity):
|
||||
"""Button entity for stopping a program."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
ButtonEntityDescription(
|
||||
key="StopProgram",
|
||||
translation_key="stop_program",
|
||||
),
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
try:
|
||||
await self.coordinator.client.stop_program(self.appliance.info.ha_id)
|
||||
except HomeConnectError as error:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="stop_program",
|
||||
translation_placeholders=get_dict_from_home_connect_error(error),
|
||||
) from error
|
@@ -1,5 +1,6 @@
|
||||
"""Common callbacks for all Home Connect platforms."""
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import cast
|
||||
@@ -9,7 +10,32 @@ from aiohomeconnect.model import EventKey
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
|
||||
|
||||
def _create_option_entities(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
list[HomeConnectOptionEntity],
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Create the required option entities for the appliances."""
|
||||
option_entities_to_add = [
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
for entity in option_entities_to_add
|
||||
}
|
||||
)
|
||||
async_add_entities(option_entities_to_add)
|
||||
|
||||
|
||||
def _handle_paired_or_connected_appliance(
|
||||
@@ -18,6 +44,12 @@ def _handle_paired_or_connected_appliance(
|
||||
get_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None,
|
||||
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Handle a new paired appliance or an appliance that has been connected.
|
||||
@@ -34,6 +66,28 @@ def _handle_paired_or_connected_appliance(
|
||||
for entity in get_entities_for_appliance(entry, appliance)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
]
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(
|
||||
entity
|
||||
for entity in get_option_entities_for_appliance(entry, appliance)
|
||||
if entity.unique_id not in known_entity_unique_ids
|
||||
)
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
@@ -47,11 +101,17 @@ def _handle_paired_or_connected_appliance(
|
||||
def _handle_depaired_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
known_entity_unique_ids: dict[str, str],
|
||||
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
|
||||
) -> None:
|
||||
"""Handle a removed appliance."""
|
||||
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
|
||||
if appliance_id not in entry.runtime_data.data:
|
||||
known_entity_unique_ids.pop(entity_unique_id, None)
|
||||
if appliance_id in changed_options_listener_remove_callbacks:
|
||||
for listener in changed_options_listener_remove_callbacks.pop(
|
||||
appliance_id
|
||||
):
|
||||
listener()
|
||||
|
||||
|
||||
def setup_home_connect_entry(
|
||||
@@ -60,13 +120,44 @@ def setup_home_connect_entry(
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
|
||||
],
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
get_option_entities_for_appliance: Callable[
|
||||
[HomeConnectConfigEntry, HomeConnectApplianceData],
|
||||
list[HomeConnectOptionEntity],
|
||||
]
|
||||
| None = None,
|
||||
) -> None:
|
||||
"""Set up the callbacks for paired and depaired appliances."""
|
||||
known_entity_unique_ids: dict[str, str] = {}
|
||||
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = (
|
||||
defaultdict(list)
|
||||
)
|
||||
|
||||
entities: list[HomeConnectEntity] = []
|
||||
for appliance in entry.runtime_data.data.values():
|
||||
entities_to_add = get_entities_for_appliance(entry, appliance)
|
||||
if get_option_entities_for_appliance:
|
||||
entities_to_add.extend(get_option_entities_for_appliance(entry, appliance))
|
||||
for event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
changed_options_listener_remove_callback = (
|
||||
entry.runtime_data.async_add_listener(
|
||||
partial(
|
||||
_create_option_entities,
|
||||
entry,
|
||||
appliance,
|
||||
known_entity_unique_ids,
|
||||
get_option_entities_for_appliance,
|
||||
async_add_entities,
|
||||
),
|
||||
(appliance.info.ha_id, event_key),
|
||||
)
|
||||
)
|
||||
entry.async_on_unload(changed_options_listener_remove_callback)
|
||||
changed_options_listener_remove_callbacks[appliance.info.ha_id].append(
|
||||
changed_options_listener_remove_callback
|
||||
)
|
||||
known_entity_unique_ids.update(
|
||||
{
|
||||
cast(str, entity.unique_id): appliance.info.ha_id
|
||||
@@ -83,6 +174,8 @@ def setup_home_connect_entry(
|
||||
entry,
|
||||
known_entity_unique_ids,
|
||||
get_entities_for_appliance,
|
||||
get_option_entities_for_appliance,
|
||||
changed_options_listener_remove_callbacks,
|
||||
async_add_entities,
|
||||
),
|
||||
(
|
||||
@@ -93,7 +186,12 @@ def setup_home_connect_entry(
|
||||
)
|
||||
entry.async_on_unload(
|
||||
entry.runtime_data.async_add_special_listener(
|
||||
partial(_handle_depaired_appliance, entry, known_entity_unique_ids),
|
||||
partial(
|
||||
_handle_depaired_appliance,
|
||||
entry,
|
||||
known_entity_unique_ids,
|
||||
changed_options_listener_remove_callbacks,
|
||||
),
|
||||
(EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
|
||||
)
|
||||
)
|
||||
|
@@ -87,7 +87,7 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
|
||||
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items()
|
||||
}
|
||||
|
||||
REFERENCE_MAP_ID_OPTIONS = {
|
||||
AVAILABLE_MAPS_ENUM = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap",
|
||||
@@ -305,7 +305,7 @@ PROGRAM_ENUM_OPTIONS = {
|
||||
for option_key, options in (
|
||||
(
|
||||
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
|
||||
REFERENCE_MAP_ID_OPTIONS,
|
||||
AVAILABLE_MAPS_ENUM,
|
||||
),
|
||||
(
|
||||
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
|
||||
|
@@ -7,16 +7,19 @@ from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import (
|
||||
CommandKey,
|
||||
Event,
|
||||
EventKey,
|
||||
EventMessage,
|
||||
EventType,
|
||||
GetSetting,
|
||||
HomeAppliance,
|
||||
OptionKey,
|
||||
ProgramKey,
|
||||
SettingKey,
|
||||
Status,
|
||||
StatusKey,
|
||||
@@ -28,7 +31,7 @@ from aiohomeconnect.model.error import (
|
||||
HomeConnectRequestError,
|
||||
UnauthorizedError,
|
||||
)
|
||||
from aiohomeconnect.model.program import EnumerateProgram
|
||||
from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
|
||||
from propcache.api import cached_property
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
@@ -51,16 +54,21 @@ EVENT_STREAM_RECONNECT_DELAY = 30
|
||||
class HomeConnectApplianceData:
|
||||
"""Class to hold Home Connect appliance data."""
|
||||
|
||||
commands: set[CommandKey]
|
||||
events: dict[EventKey, Event]
|
||||
info: HomeAppliance
|
||||
options: dict[OptionKey, ProgramDefinitionOption]
|
||||
programs: list[EnumerateProgram]
|
||||
settings: dict[SettingKey, GetSetting]
|
||||
status: dict[StatusKey, Status]
|
||||
|
||||
def update(self, other: HomeConnectApplianceData) -> None:
|
||||
"""Update data with data from other instance."""
|
||||
self.commands.update(other.commands)
|
||||
self.events.update(other.events)
|
||||
self.info.connected = other.info.connected
|
||||
self.options.clear()
|
||||
self.options.update(other.options)
|
||||
self.programs.clear()
|
||||
self.programs.extend(other.programs)
|
||||
self.settings.update(other.settings)
|
||||
@@ -172,8 +180,9 @@ class HomeConnectCoordinator(
|
||||
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)
|
||||
event_key = event.key
|
||||
if event_key in SettingKey:
|
||||
setting_key = SettingKey(event_key)
|
||||
if setting_key in settings:
|
||||
settings[setting_key].value = event.value
|
||||
else:
|
||||
@@ -183,7 +192,16 @@ class HomeConnectCoordinator(
|
||||
value=event.value,
|
||||
)
|
||||
else:
|
||||
events[event.key] = event
|
||||
if event_key in (
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
):
|
||||
await self.update_options(
|
||||
event_message_ha_id,
|
||||
event_key,
|
||||
ProgramKey(cast(str, event.value)),
|
||||
)
|
||||
events[event_key] = event
|
||||
self._call_event_listener(event_message)
|
||||
|
||||
case EventType.EVENT:
|
||||
@@ -338,6 +356,7 @@ class HomeConnectCoordinator(
|
||||
|
||||
programs = []
|
||||
events = {}
|
||||
options = {}
|
||||
if appliance.type in APPLIANCES_WITH_PROGRAMS:
|
||||
try:
|
||||
all_programs = await self.client.get_all_programs(appliance.ha_id)
|
||||
@@ -351,15 +370,17 @@ class HomeConnectCoordinator(
|
||||
)
|
||||
else:
|
||||
programs.extend(all_programs.programs)
|
||||
current_program_key = None
|
||||
program_options = None
|
||||
for program, event_key in (
|
||||
(
|
||||
all_programs.active,
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
),
|
||||
(
|
||||
all_programs.selected,
|
||||
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
|
||||
),
|
||||
(
|
||||
all_programs.active,
|
||||
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
),
|
||||
):
|
||||
if program and program.key:
|
||||
events[event_key] = Event(
|
||||
@@ -370,10 +391,41 @@ class HomeConnectCoordinator(
|
||||
"",
|
||||
program.key,
|
||||
)
|
||||
current_program_key = program.key
|
||||
program_options = program.options
|
||||
if current_program_key:
|
||||
options = await self.get_options_definitions(
|
||||
appliance.ha_id, current_program_key
|
||||
)
|
||||
for option in program_options or []:
|
||||
option_event_key = EventKey(option.key)
|
||||
events[option_event_key] = Event(
|
||||
option_event_key,
|
||||
option.key,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
option.value,
|
||||
option.name,
|
||||
display_value=option.display_value,
|
||||
unit=option.unit,
|
||||
)
|
||||
|
||||
try:
|
||||
commands = {
|
||||
command.key
|
||||
for command in (
|
||||
await self.client.get_available_commands(appliance.ha_id)
|
||||
).commands
|
||||
}
|
||||
except HomeConnectError:
|
||||
commands = set()
|
||||
|
||||
appliance_data = HomeConnectApplianceData(
|
||||
commands=commands,
|
||||
events=events,
|
||||
info=appliance,
|
||||
options=options,
|
||||
programs=programs,
|
||||
settings=settings,
|
||||
status=status,
|
||||
@@ -383,3 +435,48 @@ class HomeConnectCoordinator(
|
||||
appliance_data = appliance_data_to_update
|
||||
|
||||
return appliance_data
|
||||
|
||||
async def get_options_definitions(
|
||||
self, ha_id: str, program_key: ProgramKey
|
||||
) -> dict[OptionKey, ProgramDefinitionOption]:
|
||||
"""Get options with constraints for appliance."""
|
||||
return {
|
||||
option.key: option
|
||||
for option in (
|
||||
await self.client.get_available_program(ha_id, program_key=program_key)
|
||||
).options
|
||||
or []
|
||||
}
|
||||
|
||||
async def update_options(
|
||||
self, ha_id: str, event_key: EventKey, program_key: ProgramKey
|
||||
) -> None:
|
||||
"""Update options for appliance."""
|
||||
options = self.data[ha_id].options
|
||||
events = self.data[ha_id].events
|
||||
options_to_notify = options.copy()
|
||||
options.clear()
|
||||
if program_key is not ProgramKey.UNKNOWN:
|
||||
options.update(await self.get_options_definitions(ha_id, program_key))
|
||||
|
||||
for option in options.values():
|
||||
option_value = option.constraints.default if option.constraints else None
|
||||
if option_value is not None:
|
||||
option_event_key = EventKey(option.key)
|
||||
events[option_event_key] = Event(
|
||||
option_event_key,
|
||||
option.key.value,
|
||||
0,
|
||||
"",
|
||||
"",
|
||||
option_value,
|
||||
option.name,
|
||||
unit=option.unit,
|
||||
)
|
||||
options_to_notify.update(options)
|
||||
for option_key in options_to_notify:
|
||||
for listener in self.context_listeners.get(
|
||||
(ha_id, EventKey(option_key)),
|
||||
[],
|
||||
):
|
||||
listener()
|
||||
|
@@ -1,17 +1,22 @@
|
||||
"""Home Connect entity base class."""
|
||||
|
||||
from abc import abstractmethod
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import EventKey
|
||||
from aiohomeconnect.model import EventKey, OptionKey
|
||||
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
|
||||
return (
|
||||
self.appliance.info.connected and self._attr_available and super().available
|
||||
)
|
||||
|
||||
|
||||
class HomeConnectOptionEntity(HomeConnectEntity):
|
||||
"""Class for entities that represents program options."""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self.bsh_key in self.appliance.options
|
||||
|
||||
@property
|
||||
def option_value(self) -> str | int | float | bool | None:
|
||||
"""Return the state of the entity."""
|
||||
if event := self.appliance.events.get(EventKey(self.bsh_key)):
|
||||
return event.value
|
||||
return None
|
||||
|
||||
async def async_set_option(self, value: str | float | bool) -> None:
|
||||
"""Set an option for the entity."""
|
||||
try:
|
||||
# We try to set the active program option first,
|
||||
# if it fails we try to set the selected program option
|
||||
with contextlib.suppress(ActiveProgramNotSetError):
|
||||
await self.coordinator.client.set_active_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the active program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
return
|
||||
|
||||
await self.coordinator.client.set_selected_program_option(
|
||||
self.appliance.info.ha_id,
|
||||
option_key=self.bsh_key,
|
||||
value=value,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Updated %s for the selected program, new state: %s",
|
||||
self.entity_id,
|
||||
self.state,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="set_option",
|
||||
translation_placeholders=get_dict_from_home_connect_error(err),
|
||||
) from err
|
||||
|
||||
@property
|
||||
def bsh_key(self) -> OptionKey:
|
||||
"""Return the BSH key."""
|
||||
return cast(OptionKey, self.entity_description.key)
|
||||
|
@@ -208,6 +208,39 @@
|
||||
},
|
||||
"door-assistant_freezer": {
|
||||
"default": "mdi:door"
|
||||
},
|
||||
"silence_on_demand": {
|
||||
"default": "mdi:volume-mute",
|
||||
"state": {
|
||||
"on": "mdi:volume-mute",
|
||||
"off": "mdi:volume-high"
|
||||
}
|
||||
},
|
||||
"half_load": {
|
||||
"default": "mdi:fraction-one-half"
|
||||
},
|
||||
"hygiene_plus": {
|
||||
"default": "mdi:silverware-clean"
|
||||
},
|
||||
"eco_dry": {
|
||||
"default": "mdi:sprout"
|
||||
},
|
||||
"fast_pre_heat": {
|
||||
"default": "mdi:fire"
|
||||
},
|
||||
"i_dos_1_active": {
|
||||
"default": "mdi:numeric-1-circle"
|
||||
},
|
||||
"i_dos_2_active": {
|
||||
"default": "mdi:numeric-2-circle"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
"start_in_relative": {
|
||||
"default": "mdi:progress-clock"
|
||||
},
|
||||
"finish_in_relative": {
|
||||
"default": "mdi:progress-clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from aiohomeconnect.model import GetSetting, SettingKey
|
||||
from aiohomeconnect.model import GetSetting, OptionKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
|
||||
from homeassistant.components.number import (
|
||||
@@ -11,6 +11,7 @@ from homeassistant.components.number import (
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
@@ -24,11 +25,17 @@ from .const import (
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
)
|
||||
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
|
||||
from .entity import HomeConnectEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
UNIT_MAP = {
|
||||
"seconds": UnitOfTime.SECONDS,
|
||||
"ml": UnitOfVolume.MILLILITERS,
|
||||
"°C": UnitOfTemperature.CELSIUS,
|
||||
"°F": UnitOfTemperature.FAHRENHEIT,
|
||||
}
|
||||
|
||||
NUMBERS = (
|
||||
NumberEntityDescription(
|
||||
@@ -76,6 +83,11 @@ NUMBERS = (
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
translation_key="wine_compartment_3_setpoint_temperature",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT,
|
||||
translation_key="color_temperature_percent",
|
||||
native_unit_of_measurement="%",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL,
|
||||
device_class=NumberDeviceClass.VOLUME,
|
||||
@@ -88,6 +100,32 @@ NUMBERS = (
|
||||
),
|
||||
)
|
||||
|
||||
NUMBER_OPTIONS = (
|
||||
NumberEntityDescription(
|
||||
key=OptionKey.BSH_COMMON_DURATION,
|
||||
translation_key="duration",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
|
||||
translation_key="finish_in_relative",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=OptionKey.BSH_COMMON_START_IN_RELATIVE,
|
||||
translation_key="start_in_relative",
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY,
|
||||
translation_key="fill_quantity",
|
||||
device_class=NumberDeviceClass.VOLUME,
|
||||
native_step=1,
|
||||
),
|
||||
NumberEntityDescription(
|
||||
key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE,
|
||||
translation_key="setpoint_temperature",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
@@ -101,6 +139,18 @@ def _get_entities_for_appliance(
|
||||
]
|
||||
|
||||
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
|
||||
for description in NUMBER_OPTIONS
|
||||
if description.key in appliance.options
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
@@ -111,6 +161,7 @@ async def async_setup_entry(
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
_get_option_entities_for_appliance,
|
||||
)
|
||||
|
||||
|
||||
@@ -184,3 +235,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
|
||||
or not hasattr(self, "_attr_native_step")
|
||||
):
|
||||
await self.async_fetch_constraints()
|
||||
|
||||
|
||||
class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
|
||||
"""Number option class for Home Connect."""
|
||||
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the native value of the entity."""
|
||||
await self.async_set_option(value)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
self._attr_native_value = cast(float | None, self.option_value)
|
||||
option_definition = self.appliance.options.get(self.bsh_key)
|
||||
if option_definition:
|
||||
if option_definition.unit:
|
||||
candidate_unit = UNIT_MAP.get(
|
||||
option_definition.unit, option_definition.unit
|
||||
)
|
||||
if (
|
||||
not hasattr(self, "_attr_native_unit_of_measurement")
|
||||
or candidate_unit != self._attr_native_unit_of_measurement
|
||||
):
|
||||
self._attr_native_unit_of_measurement = candidate_unit
|
||||
self.__dict__.pop("unit_of_measurement", None)
|
||||
option_constraints = option_definition.constraints
|
||||
if option_constraints:
|
||||
if (
|
||||
not hasattr(self, "_attr_native_min_value")
|
||||
or self._attr_native_min_value != option_constraints.min
|
||||
) and option_constraints.min:
|
||||
self._attr_native_min_value = option_constraints.min
|
||||
if (
|
||||
not hasattr(self, "_attr_native_max_value")
|
||||
or self._attr_native_max_value != option_constraints.max
|
||||
) and option_constraints.max:
|
||||
self._attr_native_max_value = option_constraints.max
|
||||
if (
|
||||
not hasattr(self, "_attr_native_step")
|
||||
or self._attr_native_step != option_constraints.step_size
|
||||
) and option_constraints.step_size:
|
||||
self._attr_native_step = option_constraints.step_size
|
||||
|
@@ -1,11 +1,12 @@
|
||||
"""Provides a select platform for Home Connect."""
|
||||
|
||||
from collections.abc import Callable, Coroutine
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.client import Client as HomeConnectClient
|
||||
from aiohomeconnect.model import EventKey, ProgramKey
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import Execution
|
||||
|
||||
@@ -17,18 +18,60 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import (
|
||||
APPLIANCES_WITH_PROGRAMS,
|
||||
AVAILABLE_MAPS_ENUM,
|
||||
BEAN_AMOUNT_OPTIONS,
|
||||
BEAN_CONTAINER_OPTIONS,
|
||||
CLEANING_MODE_OPTIONS,
|
||||
COFFEE_MILK_RATIO_OPTIONS,
|
||||
COFFEE_TEMPERATURE_OPTIONS,
|
||||
DOMAIN,
|
||||
DRYING_TARGET_OPTIONS,
|
||||
FLOW_RATE_OPTIONS,
|
||||
HOT_WATER_TEMPERATURE_OPTIONS,
|
||||
INTENSIVE_LEVEL_OPTIONS,
|
||||
PROGRAMS_TRANSLATION_KEYS_MAP,
|
||||
SPIN_SPEED_OPTIONS,
|
||||
SVE_TRANSLATION_KEY_SET_SETTING,
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY,
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE,
|
||||
TEMPERATURE_OPTIONS,
|
||||
TRANSLATION_KEYS_PROGRAMS_MAP,
|
||||
VARIO_PERFECT_OPTIONS,
|
||||
VENTING_LEVEL_OPTIONS,
|
||||
WARMING_LEVEL_OPTIONS,
|
||||
)
|
||||
from .coordinator import (
|
||||
HomeConnectApplianceData,
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
|
||||
|
||||
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in (
|
||||
"Cooking.Hood.EnumType.ColorTemperature.custom",
|
||||
"Cooking.Hood.EnumType.ColorTemperature.warm",
|
||||
"Cooking.Hood.EnumType.ColorTemperature.warmToNeutral",
|
||||
"Cooking.Hood.EnumType.ColorTemperature.neutral",
|
||||
"Cooking.Hood.EnumType.ColorTemperature.neutralToCold",
|
||||
"Cooking.Hood.EnumType.ColorTemperature.cold",
|
||||
)
|
||||
}
|
||||
|
||||
AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = {
|
||||
**{
|
||||
bsh_key_to_translation_key(option): option
|
||||
for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",)
|
||||
},
|
||||
**{
|
||||
str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}"
|
||||
for option in range(1, 100)
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -44,6 +87,14 @@ class HomeConnectProgramSelectEntityDescription(
|
||||
error_translation_key: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class HomeConnectSelectEntityDescription(SelectEntityDescription):
|
||||
"""Entity Description class for settings and options that have enumeration values."""
|
||||
|
||||
translation_key_values: dict[str, str]
|
||||
values_translation_key: dict[str, str]
|
||||
|
||||
|
||||
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
|
||||
HomeConnectProgramSelectEntityDescription(
|
||||
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
|
||||
@@ -65,20 +116,225 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
|
||||
),
|
||||
)
|
||||
|
||||
SELECT_ENTITY_DESCRIPTIONS = (
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP,
|
||||
translation_key="current_map",
|
||||
options=list(AVAILABLE_MAPS_ENUM),
|
||||
translation_key_values=AVAILABLE_MAPS_ENUM,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in AVAILABLE_MAPS_ENUM.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE,
|
||||
translation_key="functional_light_color_temperature",
|
||||
options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM),
|
||||
translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
|
||||
translation_key="ambient_light_color",
|
||||
options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM),
|
||||
translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
|
||||
translation_key="reference_map_id",
|
||||
options=list(AVAILABLE_MAPS_ENUM),
|
||||
translation_key_values=AVAILABLE_MAPS_ENUM,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in AVAILABLE_MAPS_ENUM.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
|
||||
translation_key="cleaning_mode",
|
||||
options=list(CLEANING_MODE_OPTIONS),
|
||||
translation_key_values=CLEANING_MODE_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in CLEANING_MODE_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT,
|
||||
translation_key="bean_amount",
|
||||
options=list(BEAN_AMOUNT_OPTIONS),
|
||||
translation_key_values=BEAN_AMOUNT_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in BEAN_AMOUNT_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
|
||||
translation_key="coffee_temperature",
|
||||
options=list(COFFEE_TEMPERATURE_OPTIONS),
|
||||
translation_key_values=COFFEE_TEMPERATURE_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
|
||||
translation_key="bean_container",
|
||||
options=list(BEAN_CONTAINER_OPTIONS),
|
||||
translation_key_values=BEAN_CONTAINER_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in BEAN_CONTAINER_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE,
|
||||
translation_key="flow_rate",
|
||||
options=list(FLOW_RATE_OPTIONS),
|
||||
translation_key_values=FLOW_RATE_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in FLOW_RATE_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
|
||||
translation_key="coffee_milk_ratio",
|
||||
options=list(COFFEE_MILK_RATIO_OPTIONS),
|
||||
translation_key_values=COFFEE_MILK_RATIO_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in FLOW_RATE_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
|
||||
translation_key="hot_water_temperature",
|
||||
options=list(HOT_WATER_TEMPERATURE_OPTIONS),
|
||||
translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET,
|
||||
translation_key="drying_target",
|
||||
options=list(DRYING_TARGET_OPTIONS),
|
||||
translation_key_values=DRYING_TARGET_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in DRYING_TARGET_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL,
|
||||
translation_key="venting_level",
|
||||
options=list(VENTING_LEVEL_OPTIONS),
|
||||
translation_key_values=VENTING_LEVEL_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in VENTING_LEVEL_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL,
|
||||
translation_key="intensive_level",
|
||||
options=list(INTENSIVE_LEVEL_OPTIONS),
|
||||
translation_key_values=INTENSIVE_LEVEL_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.COOKING_OVEN_WARMING_LEVEL,
|
||||
translation_key="warming_level",
|
||||
options=list(WARMING_LEVEL_OPTIONS),
|
||||
translation_key_values=WARMING_LEVEL_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in WARMING_LEVEL_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
|
||||
translation_key="washer_temperature",
|
||||
options=list(TEMPERATURE_OPTIONS),
|
||||
translation_key_values=TEMPERATURE_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in TEMPERATURE_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
|
||||
translation_key="spin_speed",
|
||||
options=list(SPIN_SPEED_OPTIONS),
|
||||
translation_key_values=SPIN_SPEED_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in SPIN_SPEED_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
HomeConnectSelectEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
|
||||
translation_key="vario_perfect",
|
||||
options=list(VARIO_PERFECT_OPTIONS),
|
||||
translation_key_values=VARIO_PERFECT_OPTIONS,
|
||||
values_translation_key={
|
||||
value: translation_key
|
||||
for translation_key, value in VARIO_PERFECT_OPTIONS.items()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> list[HomeConnectEntity]:
|
||||
"""Get a list of entities."""
|
||||
return (
|
||||
return [
|
||||
*(
|
||||
[
|
||||
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
|
||||
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
|
||||
]
|
||||
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
|
||||
else []
|
||||
)
|
||||
),
|
||||
*[
|
||||
HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
|
||||
for desc in SELECT_ENTITY_DESCRIPTIONS
|
||||
if desc.key in appliance.settings
|
||||
],
|
||||
]
|
||||
|
||||
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of entities."""
|
||||
return [
|
||||
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
|
||||
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
|
||||
if desc.key in appliance.options
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -91,6 +347,7 @@ async def async_setup_entry(
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
_get_option_entities_for_appliance,
|
||||
)
|
||||
|
||||
|
||||
@@ -148,3 +405,122 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
|
||||
"""Select setting class for Home Connect."""
|
||||
|
||||
entity_description: HomeConnectSelectEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
desc,
|
||||
)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select new option."""
|
||||
value = self.entity_description.translation_key_values[option]
|
||||
try:
|
||||
await self.coordinator.client.set_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=cast(SettingKey, self.bsh_key),
|
||||
value=value,
|
||||
)
|
||||
except HomeConnectError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
|
||||
translation_placeholders={
|
||||
**get_dict_from_home_connect_error(err),
|
||||
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
|
||||
SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
|
||||
SVE_TRANSLATION_PLACEHOLDER_VALUE: value,
|
||||
},
|
||||
) from err
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
|
||||
self._attr_current_option = self.entity_description.values_translation_key.get(
|
||||
data.value
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
|
||||
if (
|
||||
not setting
|
||||
or not setting.constraints
|
||||
or not setting.constraints.allowed_values
|
||||
):
|
||||
with contextlib.suppress(HomeConnectError):
|
||||
setting = await self.coordinator.client.get_setting(
|
||||
self.appliance.info.ha_id,
|
||||
setting_key=cast(SettingKey, self.bsh_key),
|
||||
)
|
||||
|
||||
if setting and setting.constraints and setting.constraints.allowed_values:
|
||||
self._attr_options = [
|
||||
self.entity_description.values_translation_key[option]
|
||||
for option in setting.constraints.allowed_values
|
||||
if option in self.entity_description.values_translation_key
|
||||
]
|
||||
|
||||
|
||||
class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
|
||||
"""Select option class for Home Connect."""
|
||||
|
||||
entity_description: HomeConnectSelectEntityDescription
|
||||
_original_option_keys: set[str | None]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: HomeConnectCoordinator,
|
||||
appliance: HomeConnectApplianceData,
|
||||
desc: HomeConnectSelectEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._original_option_keys = set(desc.values_translation_key.keys())
|
||||
super().__init__(
|
||||
coordinator,
|
||||
appliance,
|
||||
desc,
|
||||
)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Select new option."""
|
||||
await self.async_set_option(
|
||||
self.entity_description.translation_key_values[option]
|
||||
)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
self._attr_current_option = (
|
||||
self.entity_description.values_translation_key.get(
|
||||
cast(str, self.option_value), None
|
||||
)
|
||||
if self.option_value is not None
|
||||
else None
|
||||
)
|
||||
if (
|
||||
(option_definition := self.appliance.options.get(self.bsh_key))
|
||||
and (option_constraints := option_definition.constraints)
|
||||
and option_constraints.allowed_values
|
||||
and self._original_option_keys != set(option_constraints.allowed_values)
|
||||
):
|
||||
self._original_option_keys = set(option_constraints.allowed_values)
|
||||
self._attr_options = [
|
||||
self.entity_description.values_translation_key[option]
|
||||
for option in self._original_option_keys
|
||||
if option is not None
|
||||
]
|
||||
self.__dict__.pop("options", None)
|
||||
|
@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
|
||||
SensorEntityDescription,
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util, slugify
|
||||
@@ -56,12 +56,6 @@ BSH_PROGRAM_SENSORS = (
|
||||
"WasherDryer",
|
||||
),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=EventKey.BSH_COMMON_OPTION_DURATION,
|
||||
device_class=SensorDeviceClass.DURATION,
|
||||
native_unit_of_measurement=UnitOfTime.SECONDS,
|
||||
appliance_types=("Oven",),
|
||||
),
|
||||
HomeConnectSensorEntityDescription(
|
||||
key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
|
@@ -33,6 +33,9 @@
|
||||
"appliance_not_found": {
|
||||
"message": "Appliance for device ID {device_id} not found"
|
||||
},
|
||||
"config_entry_not_found": {
|
||||
"message": "Config entry for device ID {device_id} not found"
|
||||
},
|
||||
"turn_on_light": {
|
||||
"message": "Error turning on {entity_id}: {error}"
|
||||
},
|
||||
@@ -98,6 +101,9 @@
|
||||
},
|
||||
"required_program_or_one_option_at_least": {
|
||||
"message": "A program or at least one of the possible options for a program should be specified"
|
||||
},
|
||||
"set_option": {
|
||||
"message": "Error setting the option for the program: {error}"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
@@ -105,6 +111,10 @@
|
||||
"title": "Deprecated binary door sensor detected in some automations or scripts",
|
||||
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
|
||||
},
|
||||
"deprecated_command_actions": {
|
||||
"title": "The command related actions are deprecated in favor of the new buttons",
|
||||
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
|
||||
},
|
||||
"deprecated_program_switch": {
|
||||
"title": "Deprecated program switch detected in some automations or scripts",
|
||||
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
|
||||
@@ -812,6 +822,23 @@
|
||||
"name": "Wine compartment door"
|
||||
}
|
||||
},
|
||||
"button": {
|
||||
"open_door": {
|
||||
"name": "Open door"
|
||||
},
|
||||
"partly_open_door": {
|
||||
"name": "Partly open door"
|
||||
},
|
||||
"pause_program": {
|
||||
"name": "Pause program"
|
||||
},
|
||||
"resume_program": {
|
||||
"name": "Resume program"
|
||||
},
|
||||
"stop_program": {
|
||||
"name": "Stop program"
|
||||
}
|
||||
},
|
||||
"light": {
|
||||
"cooking_lighting": {
|
||||
"name": "Functional light"
|
||||
@@ -854,11 +881,29 @@
|
||||
"wine_compartment_3_setpoint_temperature": {
|
||||
"name": "Wine compartment 3 temperature"
|
||||
},
|
||||
"color_temperature_percent": {
|
||||
"name": "Functional light color temperature percent"
|
||||
},
|
||||
"washer_i_dos_1_base_level": {
|
||||
"name": "i-Dos 1 base level"
|
||||
},
|
||||
"washer_i_dos_2_base_level": {
|
||||
"name": "i-Dos 2 base level"
|
||||
},
|
||||
"duration": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]"
|
||||
},
|
||||
"start_in_relative": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
|
||||
},
|
||||
"finish_in_relative": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
|
||||
},
|
||||
"fill_quantity": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]"
|
||||
},
|
||||
"setpoint_temperature": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
@@ -1179,6 +1224,226 @@
|
||||
"laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
|
||||
"laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
|
||||
}
|
||||
},
|
||||
"current_map": {
|
||||
"name": "Current map",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
|
||||
}
|
||||
},
|
||||
"functional_light_color_temperature": {
|
||||
"name": "Functional light color temperature",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_color_temperature_custom": "Custom",
|
||||
"cooking_hood_enum_type_color_temperature_warm": "Warm",
|
||||
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral",
|
||||
"cooking_hood_enum_type_color_temperature_neutral": "Neutral",
|
||||
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold",
|
||||
"cooking_hood_enum_type_color_temperature_cold": "Cold"
|
||||
}
|
||||
},
|
||||
"ambient_light_color": {
|
||||
"name": "Ambient light color",
|
||||
"state": {
|
||||
"b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
|
||||
}
|
||||
},
|
||||
"reference_map_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
|
||||
}
|
||||
},
|
||||
"cleaning_mode": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]",
|
||||
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]",
|
||||
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]"
|
||||
}
|
||||
},
|
||||
"bean_amount": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]"
|
||||
}
|
||||
},
|
||||
"coffee_temperature": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]"
|
||||
}
|
||||
},
|
||||
"bean_container": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]"
|
||||
}
|
||||
},
|
||||
"flow_rate": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]",
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]",
|
||||
"consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]"
|
||||
}
|
||||
},
|
||||
"coffee_milk_ratio": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]",
|
||||
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]"
|
||||
}
|
||||
},
|
||||
"hot_water_temperature": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]",
|
||||
"consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]"
|
||||
}
|
||||
},
|
||||
"drying_target": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]",
|
||||
"state": {
|
||||
"laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]",
|
||||
"laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]",
|
||||
"laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]",
|
||||
"laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]",
|
||||
"laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]"
|
||||
}
|
||||
},
|
||||
"venting_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]"
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]"
|
||||
}
|
||||
},
|
||||
"warming_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]",
|
||||
"state": {
|
||||
"cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]",
|
||||
"cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]",
|
||||
"cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]"
|
||||
}
|
||||
},
|
||||
"washer_temperature": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]",
|
||||
"laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]",
|
||||
"laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]"
|
||||
}
|
||||
},
|
||||
"spin_speed": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
|
||||
"state": {
|
||||
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
|
||||
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]"
|
||||
}
|
||||
},
|
||||
"vario_perfect": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
|
||||
"state": {
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
@@ -1365,6 +1630,45 @@
|
||||
},
|
||||
"door_assistant_freezer": {
|
||||
"name": "Freezer door assistant"
|
||||
},
|
||||
"multiple_beverages": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
|
||||
},
|
||||
"intensiv_zone": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
|
||||
},
|
||||
"brilliance_dry": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]"
|
||||
},
|
||||
"vario_speed_plus": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
|
||||
},
|
||||
"silence_on_demand": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
|
||||
},
|
||||
"half_load": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]"
|
||||
},
|
||||
"extra_dry": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]"
|
||||
},
|
||||
"hygiene_plus": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
|
||||
},
|
||||
"eco_dry": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]"
|
||||
},
|
||||
"zeolite_dry": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
|
||||
},
|
||||
"fast_pre_heat": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]"
|
||||
},
|
||||
"i_dos1_active": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
|
||||
},
|
||||
"i_dos2_active": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
|
||||
}
|
||||
},
|
||||
"time": {
|
||||
|
@@ -3,7 +3,7 @@
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
|
||||
from aiohomeconnect.model.error import HomeConnectError
|
||||
from aiohomeconnect.model.program import EnumerateProgram
|
||||
|
||||
@@ -37,7 +37,7 @@ from .coordinator import (
|
||||
HomeConnectConfigEntry,
|
||||
HomeConnectCoordinator,
|
||||
)
|
||||
from .entity import HomeConnectEntity
|
||||
from .entity import HomeConnectEntity, HomeConnectOptionEntity
|
||||
from .utils import get_dict_from_home_connect_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
|
||||
translation_key="power",
|
||||
)
|
||||
|
||||
SWITCH_OPTIONS = (
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES,
|
||||
translation_key="multiple_beverages",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE,
|
||||
translation_key="intensiv_zone",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY,
|
||||
translation_key="brilliance_dry",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS,
|
||||
translation_key="vario_speed_plus",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND,
|
||||
translation_key="silence_on_demand",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
|
||||
translation_key="half_load",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY,
|
||||
translation_key="extra_dry",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS,
|
||||
translation_key="hygiene_plus",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY,
|
||||
translation_key="eco_dry",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY,
|
||||
translation_key="zeolite_dry",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
|
||||
translation_key="fast_pre_heat",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
|
||||
translation_key="i_dos1_active",
|
||||
),
|
||||
SwitchEntityDescription(
|
||||
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
|
||||
translation_key="i_dos2_active",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _get_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
@@ -123,10 +178,21 @@ def _get_entities_for_appliance(
|
||||
for description in SWITCHES
|
||||
if description.key in appliance.settings
|
||||
)
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
def _get_option_entities_for_appliance(
|
||||
entry: HomeConnectConfigEntry,
|
||||
appliance: HomeConnectApplianceData,
|
||||
) -> list[HomeConnectOptionEntity]:
|
||||
"""Get a list of currently available option entities."""
|
||||
return [
|
||||
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
|
||||
for description in SWITCH_OPTIONS
|
||||
if description.key in appliance.options
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: HomeConnectConfigEntry,
|
||||
@@ -137,6 +203,7 @@ async def async_setup_entry(
|
||||
entry,
|
||||
_get_entities_for_appliance,
|
||||
async_add_entities,
|
||||
_get_option_entities_for_appliance,
|
||||
)
|
||||
|
||||
|
||||
@@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
|
||||
self.power_off_state = BSH_POWER_STANDBY
|
||||
else:
|
||||
self.power_off_state = None
|
||||
|
||||
|
||||
class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity):
|
||||
"""Switch option class for Home Connect."""
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn on the option."""
|
||||
await self.async_set_option(True)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn off the option."""
|
||||
await self.async_set_option(False)
|
||||
|
||||
def update_native_value(self) -> None:
|
||||
"""Set the value of the entity."""
|
||||
self._attr_is_on = cast(bool | None, self.option_value)
|
||||
|
@@ -10,7 +10,7 @@
|
||||
"loggers": ["pyhap"],
|
||||
"requirements": [
|
||||
"HAP-python==4.9.2",
|
||||
"fnv-hash-fast==1.2.2",
|
||||
"fnv-hash-fast==1.2.3",
|
||||
"PyQRCode==1.2.1",
|
||||
"base36==0.1.1"
|
||||
],
|
||||
|
@@ -10,6 +10,6 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiohue"],
|
||||
"requirements": ["aiohue==4.7.3"],
|
||||
"requirements": ["aiohue==4.7.4"],
|
||||
"zeroconf": ["_hue._tcp.local."]
|
||||
}
|
||||
|
@@ -44,7 +44,7 @@
|
||||
"fields": {
|
||||
"cycle": {
|
||||
"name": "[%key:component::input_select::services::select_next::fields::cycle::name%]",
|
||||
"description": "[%key:component::input_select::services::select_next::fields::cycle::description%]"
|
||||
"description": "If the option should cycle from the first to the last option on the list."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -49,6 +49,7 @@ from .helpers import (
|
||||
InputType,
|
||||
async_update_config_entry,
|
||||
generate_unique_id,
|
||||
purge_device_registry,
|
||||
register_lcn_address_devices,
|
||||
register_lcn_host_device,
|
||||
)
|
||||
@@ -120,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
register_lcn_host_device(hass, config_entry)
|
||||
register_lcn_address_devices(hass, config_entry)
|
||||
|
||||
# clean up orphaned devices
|
||||
purge_device_registry(hass, config_entry.entry_id, {**config_entry.data})
|
||||
|
||||
# forward config_entry to components
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
|
||||
|
@@ -3,19 +3,18 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import CONF_DOMAIN_DATA, DOMAIN
|
||||
from .const import DOMAIN
|
||||
from .helpers import (
|
||||
AddressType,
|
||||
DeviceConnectionType,
|
||||
InputType,
|
||||
generate_unique_id,
|
||||
get_device_connection,
|
||||
get_device_model,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +35,14 @@ class LcnEntity(Entity):
|
||||
self.address: AddressType = config[CONF_ADDRESS]
|
||||
self._unregister_for_inputs: Callable | None = None
|
||||
self._name: str = config[CONF_NAME]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={
|
||||
(
|
||||
DOMAIN,
|
||||
generate_unique_id(self.config_entry.entry_id, self.address),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
@@ -44,28 +51,6 @@ class LcnEntity(Entity):
|
||||
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE]
|
||||
)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo | None:
|
||||
"""Return device specific attributes."""
|
||||
address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}"
|
||||
model = (
|
||||
"LCN resource"
|
||||
f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})"
|
||||
)
|
||||
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, self.unique_id)},
|
||||
name=f"{address}.{self.config[CONF_RESOURCE]}",
|
||||
model=model,
|
||||
manufacturer="Issendorff",
|
||||
via_device=(
|
||||
DOMAIN,
|
||||
generate_unique_id(
|
||||
self.config_entry.entry_id, self.config[CONF_ADDRESS]
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added to hass."""
|
||||
self.device_connection = get_device_connection(
|
||||
|
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from itertools import chain
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
@@ -22,7 +21,6 @@ from homeassistant.const import (
|
||||
CONF_NAME,
|
||||
CONF_RESOURCE,
|
||||
CONF_SENSORS,
|
||||
CONF_SOURCE,
|
||||
CONF_SWITCHES,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -30,23 +28,14 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import (
|
||||
BINSENSOR_PORTS,
|
||||
CONF_CLIMATES,
|
||||
CONF_HARDWARE_SERIAL,
|
||||
CONF_HARDWARE_TYPE,
|
||||
CONF_OUTPUT,
|
||||
CONF_SCENES,
|
||||
CONF_SOFTWARE_SERIAL,
|
||||
CONNECTION,
|
||||
DEVICE_CONNECTIONS,
|
||||
DOMAIN,
|
||||
LED_PORTS,
|
||||
LOGICOP_PORTS,
|
||||
OUTPUT_PORTS,
|
||||
S0_INPUTS,
|
||||
SETPOINTS,
|
||||
THRESHOLDS,
|
||||
VARIABLES,
|
||||
)
|
||||
|
||||
# typing
|
||||
@@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
|
||||
raise ValueError("Unknown domain")
|
||||
|
||||
|
||||
def get_device_model(domain_name: str, domain_data: ConfigType) -> str:
|
||||
"""Return the model for the specified domain_data."""
|
||||
if domain_name in ("switch", "light"):
|
||||
return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay"
|
||||
if domain_name in ("binary_sensor", "sensor"):
|
||||
if domain_data[CONF_SOURCE] in BINSENSOR_PORTS:
|
||||
return "Binary Sensor"
|
||||
if domain_data[CONF_SOURCE] in chain(
|
||||
VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS
|
||||
):
|
||||
return "Variable"
|
||||
if domain_data[CONF_SOURCE] in LED_PORTS:
|
||||
return "Led"
|
||||
if domain_data[CONF_SOURCE] in LOGICOP_PORTS:
|
||||
return "Logical Operation"
|
||||
return "Key"
|
||||
if domain_name == "cover":
|
||||
return "Motor"
|
||||
if domain_name == "climate":
|
||||
return "Regulator"
|
||||
if domain_name == "scene":
|
||||
return "Scene"
|
||||
raise ValueError("Unknown domain")
|
||||
|
||||
|
||||
def generate_unique_id(
|
||||
entry_id: str,
|
||||
address: AddressType,
|
||||
@@ -169,13 +133,6 @@ def purge_device_registry(
|
||||
) -> None:
|
||||
"""Remove orphans from device registry which are not in entry data."""
|
||||
device_registry = dr.async_get(hass)
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
# Find all devices that are referenced in the entity registry.
|
||||
references_entities = {
|
||||
entry.device_id
|
||||
for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id)
|
||||
}
|
||||
|
||||
# Find device that references the host.
|
||||
references_host = set()
|
||||
@@ -198,7 +155,6 @@ def purge_device_registry(
|
||||
entry.id
|
||||
for entry in dr.async_entries_for_config_entry(device_registry, entry_id)
|
||||
}
|
||||
- references_entities
|
||||
- references_host
|
||||
- references_entry_data
|
||||
)
|
||||
|
@@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
|
||||
DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS,
|
||||
DeviceType.WASHTOWER: WASHER_NUMBERS,
|
||||
DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS,
|
||||
DeviceType.WATER_HEATER: (
|
||||
NumberEntityDescription(
|
||||
key=ThinQProperty.TARGET_TEMPERATURE,
|
||||
native_max_value=60,
|
||||
native_min_value=35,
|
||||
native_step=1,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
translation_key=ThinQProperty.TARGET_TEMPERATURE,
|
||||
),
|
||||
),
|
||||
DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],),
|
||||
DeviceType.WINE_CELLAR: (
|
||||
NUMBER_DESC[ThinQProperty.LIGHT_STATUS],
|
||||
NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],
|
||||
@@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity):
|
||||
) is not None:
|
||||
self._attr_native_unit_of_measurement = unit_of_measurement
|
||||
|
||||
# Undate range.
|
||||
# Update range.
|
||||
if (
|
||||
self.entity_description.native_min_value is None
|
||||
and (min_value := self.data.min) is not None
|
||||
|
@@ -581,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
|
||||
local_now = datetime.now(
|
||||
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
|
||||
)
|
||||
if value in [0, None, time.min]:
|
||||
# Reset to None
|
||||
value = None
|
||||
elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if self.entity_description.key in TIME_SENSOR_DESC:
|
||||
# Set timestamp for time
|
||||
value = local_now.replace(hour=value.hour, minute=value.minute)
|
||||
else:
|
||||
# Set timestamp for delta
|
||||
new_state = (
|
||||
self._device_state = (
|
||||
self.coordinator.data[self._device_state_id].value
|
||||
if self._device_state_id in self.coordinator.data
|
||||
else None
|
||||
)
|
||||
if (
|
||||
self.native_value is not None
|
||||
and self._device_state == new_state
|
||||
if value in [0, None, time.min] or (
|
||||
self._device_state == "power_off"
|
||||
and self.entity_description.key
|
||||
in [TimerProperty.REMAIN, TimerProperty.TOTAL]
|
||||
):
|
||||
# Skip update when same state
|
||||
return
|
||||
|
||||
self._device_state = new_state
|
||||
time_delta = timedelta(
|
||||
# Reset to None when power_off
|
||||
value = None
|
||||
elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
|
||||
if self.entity_description.key in TIME_SENSOR_DESC:
|
||||
# Set timestamp for absolute time
|
||||
value = local_now.replace(hour=value.hour, minute=value.minute)
|
||||
else:
|
||||
# Set timestamp for delta
|
||||
event_data = timedelta(
|
||||
hours=value.hour, minutes=value.minute, seconds=value.second
|
||||
)
|
||||
value = (
|
||||
(local_now - time_delta)
|
||||
new_time = (
|
||||
(local_now - event_data)
|
||||
if self.entity_description.key == TimerProperty.RUNNING
|
||||
else (local_now + time_delta)
|
||||
else (local_now + event_data)
|
||||
)
|
||||
# The remain_time may change during the wash/dry operation depending on various reasons.
|
||||
# If there is a diff of more than 60sec, the new timestamp is used
|
||||
if (
|
||||
parse_native_value := dt_util.parse_datetime(
|
||||
str(self.native_value)
|
||||
)
|
||||
) is None or abs(new_time - parse_native_value) > timedelta(
|
||||
seconds=60
|
||||
):
|
||||
value = new_time
|
||||
else:
|
||||
value = self.native_value
|
||||
elif self.entity_description.device_class == SensorDeviceClass.DURATION:
|
||||
# Set duration
|
||||
value = self._get_duration(
|
||||
|
@@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30"
|
||||
MODELS_ARYLIC_A50: Final[str] = "A50"
|
||||
MODELS_ARYLIC_A50S: Final[str] = "A50+"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
|
||||
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
|
||||
MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
|
||||
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
|
||||
MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+"
|
||||
MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
|
||||
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
|
||||
MODELS_WIIM_AMP: Final[str] = "WiiM Amp"
|
||||
@@ -49,9 +51,10 @@ PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = {
|
||||
"UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
|
||||
"UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
|
||||
"UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3),
|
||||
"S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P),
|
||||
"ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
|
||||
"UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1),
|
||||
"RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
"ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
|
||||
|
@@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate):
|
||||
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
|
||||
self._attr_device_info = self.api.device_info
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# We can only check for vane_horizontal once we fetch the device data from the cloud
|
||||
if self._device.vane_horizontal:
|
||||
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
"""Return the optional state attributes with device specific additions."""
|
||||
@@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate):
|
||||
"""Return vertical vane position or mode."""
|
||||
return self._device.vane_vertical
|
||||
|
||||
@property
|
||||
def swing_horizontal_mode(self) -> str | None:
|
||||
"""Return horizontal vane position or mode."""
|
||||
return self._device.vane_horizontal
|
||||
|
||||
async def async_set_swing_mode(self, swing_mode: str) -> None:
|
||||
"""Set vertical vane position or mode."""
|
||||
await self.async_set_vane_vertical(swing_mode)
|
||||
|
||||
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
|
||||
"""Set horizontal vane position or mode."""
|
||||
await self.async_set_vane_horizontal(swing_horizontal_mode)
|
||||
|
||||
@property
|
||||
def swing_modes(self) -> list[str] | None:
|
||||
"""Return a list of available vertical vane positions and modes."""
|
||||
return self._device.vane_vertical_positions
|
||||
|
||||
@property
|
||||
def swing_horizontal_modes(self) -> list[str] | None:
|
||||
"""Return a list of available horizontal vane positions and modes."""
|
||||
return self._device.vane_horizontal_positions
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn the entity on."""
|
||||
await self._device.set({"power": True})
|
||||
|
@@ -9,15 +9,13 @@ import dns.rdata
|
||||
import dns.rdataclass
|
||||
import dns.rdatatype
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
|
||||
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
|
||||
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
|
||||
from .coordinator import MinecraftServerCoordinator
|
||||
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
|
||||
|
||||
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
|
||||
@@ -31,32 +29,18 @@ def load_dnspython_rdata_classes() -> None:
|
||||
dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: MinecraftServerConfigEntry
|
||||
) -> bool:
|
||||
"""Set up Minecraft Server from a config entry."""
|
||||
|
||||
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
|
||||
await hass.async_add_executor_job(load_dnspython_rdata_classes)
|
||||
|
||||
# Create API instance.
|
||||
api = MinecraftServer(
|
||||
hass,
|
||||
entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
|
||||
entry.data[CONF_ADDRESS],
|
||||
)
|
||||
|
||||
# Initialize API instance.
|
||||
try:
|
||||
await api.async_initialize()
|
||||
except MinecraftServerAddressError as error:
|
||||
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
|
||||
|
||||
# Create coordinator instance.
|
||||
coordinator = MinecraftServerCoordinator(hass, entry, api)
|
||||
# Create coordinator instance and store it.
|
||||
coordinator = MinecraftServerCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
# Store coordinator instance.
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
domain_data[entry.entry_id] = coordinator
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
# Set up platforms.
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
@@ -64,21 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
|
||||
) -> bool:
|
||||
"""Unload Minecraft Server config entry."""
|
||||
|
||||
# Unload platforms.
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
)
|
||||
|
||||
# Clean up.
|
||||
hass.data[DOMAIN].pop(config_entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
|
||||
async def async_migrate_entry(
|
||||
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
|
||||
) -> bool:
|
||||
"""Migrate old config entry to a new format."""
|
||||
|
||||
# 1 --> 2: Use config entry ID as base for unique IDs.
|
||||
@@ -152,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
|
||||
|
||||
|
||||
async def _async_migrate_device_identifiers(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None
|
||||
hass: HomeAssistant,
|
||||
config_entry: MinecraftServerConfigEntry,
|
||||
old_unique_id: str | None,
|
||||
) -> None:
|
||||
"""Migrate the device identifiers to the new format."""
|
||||
device_registry = dr.async_get(hass)
|
||||
|
@@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import (
|
||||
BinarySensorEntity,
|
||||
BinarySensorEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MinecraftServerCoordinator
|
||||
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
|
||||
from .entity import MinecraftServerEntity
|
||||
|
||||
KEY_STATUS = "status"
|
||||
@@ -27,11 +25,11 @@ BINARY_SENSOR_DESCRIPTIONS = [
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MinecraftServerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Minecraft Server binary sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
# Add binary sensor entities.
|
||||
async_add_entities(
|
||||
@@ -49,7 +47,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit
|
||||
self,
|
||||
coordinator: MinecraftServerCoordinator,
|
||||
description: BinarySensorEntityDescription,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MinecraftServerConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize binary sensor base entity."""
|
||||
super().__init__(coordinator, config_entry)
|
||||
|
@@ -8,10 +8,10 @@ from typing import Any
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_TYPE
|
||||
|
||||
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
|
||||
from .const import DEFAULT_NAME, DOMAIN
|
||||
from .const import DOMAIN
|
||||
|
||||
DEFAULT_ADDRESS = "localhost:25565"
|
||||
|
||||
@@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Prepare config entry data.
|
||||
config_data = {
|
||||
CONF_NAME: user_input[CONF_NAME],
|
||||
CONF_ADDRESS: address,
|
||||
}
|
||||
|
||||
@@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(
|
||||
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
|
||||
): str,
|
||||
vol.Required(
|
||||
CONF_ADDRESS,
|
||||
default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS),
|
||||
|
@@ -1,7 +1,5 @@
|
||||
"""Constants for the Minecraft Server integration."""
|
||||
|
||||
DEFAULT_NAME = "Minecraft Server"
|
||||
|
||||
DOMAIN = "minecraft_server"
|
||||
|
||||
KEY_LATENCY = "latency"
|
||||
|
@@ -6,17 +6,22 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_TYPE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .api import (
|
||||
MinecraftServer,
|
||||
MinecraftServerAddressError,
|
||||
MinecraftServerConnectionError,
|
||||
MinecraftServerData,
|
||||
MinecraftServerNotInitializedError,
|
||||
MinecraftServerType,
|
||||
)
|
||||
|
||||
type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator]
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -25,25 +30,40 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
|
||||
"""Minecraft Server data update coordinator."""
|
||||
|
||||
config_entry: ConfigEntry
|
||||
config_entry: MinecraftServerConfigEntry
|
||||
_api: MinecraftServer
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
api: MinecraftServer,
|
||||
config_entry: MinecraftServerConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize coordinator instance."""
|
||||
self._api = api
|
||||
|
||||
super().__init__(
|
||||
hass=hass,
|
||||
name=config_entry.data[CONF_NAME],
|
||||
name=config_entry.title,
|
||||
config_entry=config_entry,
|
||||
logger=_LOGGER,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the Minecraft Server data coordinator."""
|
||||
|
||||
# Create API instance.
|
||||
self._api = MinecraftServer(
|
||||
self.hass,
|
||||
self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
|
||||
self.config_entry.data[CONF_ADDRESS],
|
||||
)
|
||||
|
||||
# Initialize API instance.
|
||||
try:
|
||||
await self._api.async_initialize()
|
||||
except MinecraftServerAddressError as error:
|
||||
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
|
||||
|
||||
async def _async_update_data(self) -> MinecraftServerData:
|
||||
"""Get updated data from the server."""
|
||||
try:
|
||||
|
@@ -5,20 +5,19 @@ from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_ADDRESS, CONF_NAME
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import MinecraftServerConfigEntry
|
||||
|
||||
TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"}
|
||||
TO_REDACT: Iterable[Any] = {CONF_ADDRESS, "players_list"}
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry
|
||||
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
return {
|
||||
"config_entry": {
|
||||
|
@@ -6,9 +6,7 @@ rules:
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Check removal and replacement of name in config flow with the title (server address).
|
||||
config-flow: done
|
||||
config-flow-test-coverage:
|
||||
status: todo
|
||||
comment: |
|
||||
@@ -29,7 +27,7 @@ rules:
|
||||
status: done
|
||||
comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information.
|
||||
has-entity-name: done
|
||||
runtime-data: todo
|
||||
runtime-data: done
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: done
|
||||
|
@@ -7,15 +7,14 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .api import MinecraftServerData, MinecraftServerType
|
||||
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
|
||||
from .coordinator import MinecraftServerCoordinator
|
||||
from .const import KEY_LATENCY, KEY_MOTD
|
||||
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
|
||||
from .entity import MinecraftServerEntity
|
||||
|
||||
ATTR_PLAYERS_LIST = "players_list"
|
||||
@@ -158,11 +157,11 @@ SENSOR_DESCRIPTIONS = [
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MinecraftServerConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Minecraft Server sensor platform."""
|
||||
coordinator = hass.data[DOMAIN][config_entry.entry_id]
|
||||
coordinator = config_entry.runtime_data
|
||||
|
||||
# Add sensor entities.
|
||||
async_add_entities(
|
||||
@@ -184,7 +183,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity):
|
||||
self,
|
||||
coordinator: MinecraftServerCoordinator,
|
||||
description: MinecraftServerSensorEntityDescription,
|
||||
config_entry: ConfigEntry,
|
||||
config_entry: MinecraftServerConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize sensor base entity."""
|
||||
super().__init__(coordinator, config_entry)
|
||||
|
@@ -2,12 +2,14 @@
|
||||
"config": {
|
||||
"step": {
|
||||
"user": {
|
||||
"title": "Link your Minecraft Server",
|
||||
"description": "Set up your Minecraft Server instance to allow monitoring.",
|
||||
"data": {
|
||||
"name": "[%key:common::config_flow::data::name%]",
|
||||
"address": "Server address"
|
||||
}
|
||||
},
|
||||
"data_description": {
|
||||
"address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port."
|
||||
},
|
||||
"title": "Link your Minecraft Server",
|
||||
"description": "Set up your Minecraft Server instance to allow monitoring."
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
|
@@ -384,6 +384,11 @@ class ModbusHub:
|
||||
{ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1}
|
||||
)
|
||||
entry = self._pb_request[use_call]
|
||||
|
||||
if use_call in {"write_registers", "write_coils"}:
|
||||
if not isinstance(value, list):
|
||||
value = [value]
|
||||
|
||||
kwargs[entry.value_attr_name] = value
|
||||
try:
|
||||
result: ModbusPDU = await entry.func(address, **kwargs)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "motionmount",
|
||||
"name": "Vogel's MotionMount",
|
||||
"codeowners": ["@RJPoelstra"],
|
||||
"codeowners": ["@laiho-vogels"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/motionmount",
|
||||
"integration_type": "device",
|
||||
|
@@ -58,12 +58,12 @@
|
||||
},
|
||||
"services": {
|
||||
"lock_n_go": {
|
||||
"name": "Lock 'n' go",
|
||||
"description": "Nuki Lock 'n' Go.",
|
||||
"name": "Lock 'n' Go",
|
||||
"description": "Unlocks the door, waits a few seconds then re-locks. The wait period can be customized through the app.",
|
||||
"fields": {
|
||||
"unlatch": {
|
||||
"name": "Unlatch",
|
||||
"description": "Whether to unlatch the lock."
|
||||
"description": "Whether to also unlatch the door when unlocking it."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics(
|
||||
hass_device = device_registry.async_get_device(
|
||||
identifiers={(DOMAIN, hass_data.unique_id)}
|
||||
)
|
||||
if not hass_device:
|
||||
return data
|
||||
# Device is always created
|
||||
assert hass_device is not None
|
||||
|
||||
data["device"] = {
|
||||
**attr.asdict(hass_device),
|
||||
|
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ambient_humidity_status": {
|
||||
"default": "mdi:information-outline"
|
||||
},
|
||||
"ambient_temperature_status": {
|
||||
"default": "mdi:information-outline"
|
||||
},
|
||||
"battery_alarm_threshold": {
|
||||
"default": "mdi:information-outline"
|
||||
},
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "nut",
|
||||
"name": "Network UPS Tools (NUT)",
|
||||
"codeowners": ["@bdraco", "@ollo69", "@pestevez"],
|
||||
"codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/nut",
|
||||
"integration_type": "device",
|
||||
|
@@ -46,8 +46,17 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
|
||||
"serial": ATTR_SERIAL_NUMBER,
|
||||
}
|
||||
|
||||
AMBIENT_THRESHOLD_STATUS_OPTIONS = [
|
||||
"good",
|
||||
"warning-low",
|
||||
"critical-low",
|
||||
"warning-high",
|
||||
"critical-high",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
|
||||
"ups.status.display": SensorEntityDescription(
|
||||
key="ups.status.display",
|
||||
@@ -930,6 +939,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"ambient.humidity.status": SensorEntityDescription(
|
||||
key="ambient.humidity.status",
|
||||
translation_key="ambient_humidity_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=AMBIENT_THRESHOLD_STATUS_OPTIONS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"ambient.temperature": SensorEntityDescription(
|
||||
key="ambient.temperature",
|
||||
translation_key="ambient_temperature",
|
||||
@@ -938,6 +954,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"ambient.temperature.status": SensorEntityDescription(
|
||||
key="ambient.temperature.status",
|
||||
translation_key="ambient_temperature_status",
|
||||
device_class=SensorDeviceClass.ENUM,
|
||||
options=AMBIENT_THRESHOLD_STATUS_OPTIONS,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
"watts": SensorEntityDescription(
|
||||
key="watts",
|
||||
translation_key="watts",
|
||||
|
@@ -80,7 +80,9 @@
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"ambient_humidity": { "name": "Ambient humidity" },
|
||||
"ambient_humidity_status": { "name": "Ambient humidity status" },
|
||||
"ambient_temperature": { "name": "Ambient temperature" },
|
||||
"ambient_temperature_status": { "name": "Ambient temperature status" },
|
||||
"battery_alarm_threshold": { "name": "Battery alarm threshold" },
|
||||
"battery_capacity": { "name": "Battery capacity" },
|
||||
"battery_charge": { "name": "Battery charge" },
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "onboarding",
|
||||
"name": "Home Assistant Onboarding",
|
||||
"after_dependencies": ["backup", "hassio"],
|
||||
"after_dependencies": ["backup"],
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"dependencies": ["auth", "http", "person"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/onboarding",
|
||||
|
@@ -29,7 +29,6 @@ from homeassistant.components.http.view import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import area_registry as ar
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.system_info import async_get_system_info
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.setup import async_setup_component
|
||||
@@ -224,16 +223,6 @@ class CoreConfigOnboardingView(_BaseOnboardingView):
|
||||
"shopping_list",
|
||||
]
|
||||
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components import hassio
|
||||
|
||||
if (
|
||||
is_hassio(hass)
|
||||
and (core_info := hassio.get_core_info(hass))
|
||||
and "raspberrypi" in core_info["machine"]
|
||||
):
|
||||
onboard_integrations.append("rpi_power")
|
||||
|
||||
for domain in onboard_integrations:
|
||||
# Create tasks so onboarding isn't affected
|
||||
# by errors in these integrations.
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from html import unescape
|
||||
from json import dumps, loads
|
||||
import logging
|
||||
@@ -10,13 +11,13 @@ from typing import cast
|
||||
from onedrive_personal_sdk import OneDriveClient
|
||||
from onedrive_personal_sdk.exceptions import (
|
||||
AuthenticationError,
|
||||
HttpRequestException,
|
||||
NotFoundError,
|
||||
OneDriveException,
|
||||
)
|
||||
from onedrive_personal_sdk.models.items import ItemUpdate
|
||||
from onedrive_personal_sdk.models.items import Item, ItemUpdate
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
@@ -25,7 +26,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
)
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
|
||||
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
|
||||
from .coordinator import (
|
||||
OneDriveConfigEntry,
|
||||
OneDriveRuntimeData,
|
||||
@@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
|
||||
|
||||
# get approot, will be created automatically if it does not exist
|
||||
try:
|
||||
approot = await client.get_approot()
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from err
|
||||
except (HttpRequestException, OneDriveException, TimeoutError) as err:
|
||||
_LOGGER.debug("Failed to get approot", exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_get_folder",
|
||||
translation_placeholders={"folder": "approot"},
|
||||
) from err
|
||||
approot = await _handle_item_operation(client.get_approot, "approot")
|
||||
folder_name = entry.data[CONF_FOLDER_NAME]
|
||||
|
||||
instance_id = await async_get_instance_id(hass)
|
||||
backup_folder_name = f"backups_{instance_id[:8]}"
|
||||
try:
|
||||
backup_folder = await client.create_folder(
|
||||
parent_id=approot.id, name=backup_folder_name
|
||||
backup_folder = await _handle_item_operation(
|
||||
lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]),
|
||||
folder_name,
|
||||
)
|
||||
except NotFoundError:
|
||||
_LOGGER.debug("Creating backup folder %s", folder_name)
|
||||
backup_folder = await _handle_item_operation(
|
||||
lambda: client.create_folder(parent_id=approot.id, name=folder_name),
|
||||
folder_name,
|
||||
)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id}
|
||||
)
|
||||
|
||||
# write instance id to description
|
||||
if backup_folder.description != (instance_id := await async_get_instance_id(hass)):
|
||||
await _handle_item_operation(
|
||||
lambda: client.update_drive_item(
|
||||
backup_folder.id, ItemUpdate(description=instance_id)
|
||||
),
|
||||
folder_name,
|
||||
)
|
||||
|
||||
# update in case folder was renamed manually inside OneDrive
|
||||
if backup_folder.name != entry.data[CONF_FOLDER_NAME]:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name}
|
||||
)
|
||||
except (HttpRequestException, OneDriveException, TimeoutError) as err:
|
||||
_LOGGER.debug("Failed to create backup folder", exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_get_folder",
|
||||
translation_placeholders={"folder": backup_folder_name},
|
||||
) from err
|
||||
|
||||
coordinator = OneDriveUpdateCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
@@ -96,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
translation_key="failed_to_migrate_files",
|
||||
) from err
|
||||
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
|
||||
@@ -104,25 +109,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(update_listener))
|
||||
|
||||
def async_notify_backup_listeners() -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
|
||||
"""Unload a OneDrive config entry."""
|
||||
_async_notify_backup_listeners_soon(hass)
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
|
||||
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
|
||||
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
|
||||
listener()
|
||||
|
||||
|
||||
@callback
|
||||
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
|
||||
hass.loop.call_soon(_async_notify_backup_listeners, hass)
|
||||
|
||||
|
||||
async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None:
|
||||
"""Migrate backup files to metadata version 2."""
|
||||
files = await client.list_drive_items(backup_folder_id)
|
||||
@@ -152,3 +152,47 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -
|
||||
data=ItemUpdate(description=""),
|
||||
)
|
||||
_LOGGER.debug("Migrated backup file %s", file.name)
|
||||
|
||||
|
||||
async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
|
||||
"""Migrate old entry."""
|
||||
if entry.version > 1:
|
||||
# This means the user has downgraded from a future version
|
||||
return False
|
||||
|
||||
if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1:
|
||||
_LOGGER.debug(
|
||||
"Migrating OneDrive config entry from version %s.%s", version, minor_version
|
||||
)
|
||||
|
||||
instance_id = await async_get_instance_id(hass)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={
|
||||
**entry.data,
|
||||
CONF_FOLDER_ID: "id", # will be updated during setup_entry
|
||||
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
|
||||
},
|
||||
)
|
||||
_LOGGER.debug("Migration to version 1.2 successful")
|
||||
return True
|
||||
|
||||
|
||||
async def _handle_item_operation(
|
||||
func: Callable[[], Awaitable[Item]], folder: str
|
||||
) -> Item:
|
||||
try:
|
||||
return await func()
|
||||
except NotFoundError:
|
||||
raise
|
||||
except AuthenticationError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN, translation_key="authentication_failed"
|
||||
) from err
|
||||
except (OneDriveException, TimeoutError) as err:
|
||||
_LOGGER.debug("Failed to get approot", exc_info=True)
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_get_folder",
|
||||
translation_placeholders={"folder": folder},
|
||||
) from err
|
||||
|
@@ -74,7 +74,7 @@ def async_register_backup_agents_listener(
|
||||
def handle_backup_errors[_R, **P](
|
||||
func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]],
|
||||
) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]:
|
||||
"""Handle backup errors with a specific translation key."""
|
||||
"""Handle backup errors."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(
|
||||
|
@@ -8,22 +8,47 @@ from typing import Any, cast
|
||||
|
||||
from onedrive_personal_sdk.clients.client import OneDriveClient
|
||||
from onedrive_personal_sdk.exceptions import OneDriveException
|
||||
from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_RECONFIGURE,
|
||||
SOURCE_USER,
|
||||
ConfigFlowResult,
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
|
||||
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
|
||||
|
||||
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
|
||||
from .const import (
|
||||
CONF_DELETE_PERMANENTLY,
|
||||
CONF_FOLDER_ID,
|
||||
CONF_FOLDER_NAME,
|
||||
DOMAIN,
|
||||
OAUTH_SCOPES,
|
||||
)
|
||||
from .coordinator import OneDriveConfigEntry
|
||||
|
||||
FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str})
|
||||
|
||||
|
||||
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Config flow to handle OneDrive OAuth2 authentication."""
|
||||
|
||||
DOMAIN = DOMAIN
|
||||
MINOR_VERSION = 2
|
||||
|
||||
client: OneDriveClient
|
||||
approot: AppRoot
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the OneDrive config flow."""
|
||||
super().__init__()
|
||||
self.step_data: dict[str, Any] = {}
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
@@ -35,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"scope": " ".join(OAUTH_SCOPES)}
|
||||
|
||||
@property
|
||||
def apps_folder(self) -> str:
|
||||
"""Return the name of the Apps folder (translated)."""
|
||||
return (
|
||||
path.split("/")[-1]
|
||||
if (path := self.approot.parent_reference.path)
|
||||
else "Apps"
|
||||
)
|
||||
|
||||
async def async_oauth_create_entry(
|
||||
self,
|
||||
data: dict[str, Any],
|
||||
@@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
async def get_access_token() -> str:
|
||||
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
|
||||
|
||||
graph_client = OneDriveClient(
|
||||
self.client = OneDriveClient(
|
||||
get_access_token, async_get_clientsession(self.hass)
|
||||
)
|
||||
|
||||
try:
|
||||
approot = await graph_client.get_approot()
|
||||
self.approot = await self.client.get_approot()
|
||||
except OneDriveException:
|
||||
self.logger.exception("Failed to connect to OneDrive")
|
||||
return self.async_abort(reason="connection_error")
|
||||
@@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
self.logger.exception("Unknown error")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
await self.async_set_unique_id(approot.parent_reference.drive_id)
|
||||
await self.async_set_unique_id(self.approot.parent_reference.drive_id)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if self.source != SOURCE_USER:
|
||||
self._abort_if_unique_id_mismatch(
|
||||
reason="wrong_drive",
|
||||
)
|
||||
|
||||
if self.source == SOURCE_REAUTH:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
return self.async_update_reload_and_abort(
|
||||
entry=reauth_entry,
|
||||
data=data,
|
||||
)
|
||||
|
||||
if self.source != SOURCE_RECONFIGURE:
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.step_data = data
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
return await self.async_step_reconfigure_folder()
|
||||
|
||||
return await self.async_step_folder_name()
|
||||
|
||||
async def async_step_folder_name(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Step to ask for the folder name."""
|
||||
errors: dict[str, str] = {}
|
||||
instance_id = await async_get_instance_id(self.hass)
|
||||
if user_input is not None:
|
||||
try:
|
||||
folder = await self.client.create_folder(
|
||||
self.approot.id, user_input[CONF_FOLDER_NAME]
|
||||
)
|
||||
except OneDriveException:
|
||||
self.logger.debug("Failed to create folder", exc_info=True)
|
||||
errors["base"] = "folder_creation_error"
|
||||
else:
|
||||
if folder.description and folder.description != instance_id:
|
||||
errors[CONF_FOLDER_NAME] = "folder_already_in_use"
|
||||
if not errors:
|
||||
title = (
|
||||
f"{approot.created_by.user.display_name}'s OneDrive"
|
||||
if approot.created_by.user and approot.created_by.user.display_name
|
||||
f"{self.approot.created_by.user.display_name}'s OneDrive"
|
||||
if self.approot.created_by.user
|
||||
and self.approot.created_by.user.display_name
|
||||
else "OneDrive"
|
||||
)
|
||||
return self.async_create_entry(title=title, data=data)
|
||||
return self.async_create_entry(
|
||||
title=title,
|
||||
data={
|
||||
**self.step_data,
|
||||
CONF_FOLDER_ID: folder.id,
|
||||
CONF_FOLDER_NAME: user_input[CONF_FOLDER_NAME],
|
||||
},
|
||||
)
|
||||
|
||||
default_folder_name = (
|
||||
f"backups_{instance_id[:8]}"
|
||||
if user_input is None
|
||||
else user_input[CONF_FOLDER_NAME]
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="folder_name",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
FOLDER_NAME_SCHEMA, {CONF_FOLDER_NAME: default_folder_name}
|
||||
),
|
||||
description_placeholders={
|
||||
"apps_folder": self.apps_folder,
|
||||
"approot": self.approot.name,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure_folder(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Reconfigure the folder name."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
if (
|
||||
new_folder_name := user_input[CONF_FOLDER_NAME]
|
||||
) != reconfigure_entry.data[CONF_FOLDER_NAME]:
|
||||
try:
|
||||
await self.client.update_drive_item(
|
||||
reconfigure_entry.data[CONF_FOLDER_ID],
|
||||
ItemUpdate(name=new_folder_name),
|
||||
)
|
||||
except OneDriveException:
|
||||
self.logger.debug("Failed to update folder", exc_info=True)
|
||||
errors["base"] = "folder_rename_error"
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
data={**reconfigure_entry.data, CONF_FOLDER_NAME: new_folder_name},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure_folder",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
FOLDER_NAME_SCHEMA,
|
||||
{CONF_FOLDER_NAME: reconfigure_entry.data[CONF_FOLDER_NAME]},
|
||||
),
|
||||
description_placeholders={
|
||||
"apps_folder": self.apps_folder,
|
||||
"approot": self.approot.name,
|
||||
},
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
@@ -92,6 +218,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Reconfigure the entry."""
|
||||
return await self.async_step_user()
|
||||
|
||||
@staticmethod
|
||||
@callback
|
||||
def async_get_options_flow(
|
||||
|
@@ -6,6 +6,8 @@ from typing import Final
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
DOMAIN: Final = "onedrive"
|
||||
CONF_FOLDER_NAME: Final = "folder_name"
|
||||
CONF_FOLDER_ID: Final = "folder_id"
|
||||
|
||||
CONF_DELETE_PERMANENTLY: Final = "delete_permanently"
|
||||
|
||||
|
@@ -8,6 +8,6 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "bronze",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.0.11"]
|
||||
}
|
||||
|
@@ -73,10 +73,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
Nothing to reconfigure.
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
status: exempt
|
||||
|
@@ -7,6 +7,26 @@
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "The OneDrive integration needs to re-authenticate your account"
|
||||
},
|
||||
"folder_name": {
|
||||
"title": "Pick a folder name",
|
||||
"description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`",
|
||||
"data": {
|
||||
"folder_name": "Folder name"
|
||||
},
|
||||
"data_description": {
|
||||
"folder_name": "Name of the folder"
|
||||
}
|
||||
},
|
||||
"reconfigure_folder": {
|
||||
"title": "Change the folder name",
|
||||
"description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.",
|
||||
"data": {
|
||||
"folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]"
|
||||
},
|
||||
"data_description": {
|
||||
"folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"abort": {
|
||||
@@ -23,10 +43,16 @@
|
||||
"connection_error": "Failed to connect to OneDrive.",
|
||||
"wrong_drive": "New account does not contain previously configured OneDrive.",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"error": {
|
||||
"folder_rename_error": "Failed to rename folder",
|
||||
"folder_creation_error": "Failed to create folder",
|
||||
"folder_already_in_use": "Folder already used for backups from another Home Assistant instance"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
@@ -287,6 +287,9 @@ class OpenAIConversationEntity(
|
||||
|
||||
try:
|
||||
result = await client.chat.completions.create(**model_args)
|
||||
except openai.RateLimitError as err:
|
||||
LOGGER.error("Rate limited by OpenAI: %s", err)
|
||||
raise HomeAssistantError("Rate limited or insufficient funds") from err
|
||||
except openai.OpenAIError as err:
|
||||
LOGGER.error("Error talking to OpenAI: %s", err)
|
||||
raise HomeAssistantError("Error talking to OpenAI") from err
|
||||
|
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/prosegur",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyprosegur"],
|
||||
"requirements": ["pyprosegur==0.0.13"]
|
||||
"requirements": ["pyprosegur==0.0.14"]
|
||||
}
|
||||
|
@@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
|
||||
)
|
||||
return None
|
||||
|
||||
distance_to_zone = distance(
|
||||
distance_to_centre = distance(
|
||||
zone.attributes[ATTR_LATITUDE],
|
||||
zone.attributes[ATTR_LONGITUDE],
|
||||
latitude,
|
||||
@@ -172,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
|
||||
)
|
||||
|
||||
# it is ensured, that distance can't be None, since zones must have lat/lon coordinates
|
||||
assert distance_to_zone is not None
|
||||
return round(distance_to_zone)
|
||||
assert distance_to_centre is not None
|
||||
|
||||
zone_radius: float = zone.attributes["radius"]
|
||||
if zone_radius > distance_to_centre:
|
||||
# we've arrived the zone
|
||||
return 0
|
||||
return round(distance_to_centre - zone_radius)
|
||||
|
||||
def _calc_direction_of_travel(
|
||||
self,
|
||||
|
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyloadapi.api import PyLoadAPI
|
||||
from pyloadapi import PyLoadAPI
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI
|
||||
from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
@@ -7,8 +7,7 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import CookieJar
|
||||
from pyloadapi.api import PyLoadAPI
|
||||
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
|
||||
from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
|
@@ -78,10 +78,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
|
||||
return self.data
|
||||
except CannotConnect as e:
|
||||
raise UpdateFailed(
|
||||
"Unable to connect and retrieve data from pyLoad API"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_request_exception",
|
||||
) from e
|
||||
except ParserError as e:
|
||||
raise UpdateFailed("Unable to parse data from pyLoad API") from e
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="setup_parse_exception",
|
||||
) from e
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Set up the coordinator."""
|
||||
|
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyloadapi"],
|
||||
"requirements": ["PyLoadAPI==1.4.1"]
|
||||
"requirements": ["PyLoadAPI==1.4.2"]
|
||||
}
|
||||
|
@@ -12,7 +12,11 @@
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the device running your pyLoad instance.",
|
||||
"port": "pyLoad uses port 8000 by default."
|
||||
"username": "The username used to access the pyLoad instance.",
|
||||
"password": "The password associated with the pyLoad account.",
|
||||
"port": "pyLoad uses port 8000 by default.",
|
||||
"ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.",
|
||||
"verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection."
|
||||
}
|
||||
},
|
||||
"reconfigure": {
|
||||
@@ -25,8 +29,12 @@
|
||||
"port": "[%key:common::config_flow::data::port%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the device running your pyLoad instance.",
|
||||
"port": "pyLoad uses port 8000 by default."
|
||||
"host": "[%key:component::pyload::config::step::user::data_description::host%]",
|
||||
"verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]",
|
||||
"username": "[%key:component::pyload::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::pyload::config::step::user::data_description::password%]",
|
||||
"port": "[%key:component::pyload::config::step::user::data_description::port%]",
|
||||
"ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
@@ -34,6 +42,10 @@
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"username": "[%key:component::pyload::config::step::user::data_description::username%]",
|
||||
"password": "[%key:component::pyload::config::step::user::data_description::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -91,10 +103,10 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"setup_request_exception": {
|
||||
"message": "Unable to connect and retrieve data from pyLoad API, try again later"
|
||||
"message": "Unable to connect and retrieve data from pyLoad API"
|
||||
},
|
||||
"setup_parse_exception": {
|
||||
"message": "Unable to parse data from pyLoad API, try again later"
|
||||
"message": "Unable to parse data from pyLoad API"
|
||||
},
|
||||
"setup_authentication_exception": {
|
||||
"message": "Authentication failed for {username}, verify your login credentials"
|
||||
|
@@ -7,7 +7,7 @@ from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
|
||||
from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI
|
||||
from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
SwitchDeviceClass,
|
||||
|
@@ -8,6 +8,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import recorder as recorder_helper
|
||||
|
||||
from .util import get_instance
|
||||
|
||||
@@ -23,12 +24,15 @@ def async_setup(hass: HomeAssistant) -> None:
|
||||
vol.Required("type"): "recorder/info",
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_info(
|
||||
@websocket_api.async_response
|
||||
async def ws_info(
|
||||
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Return status of the recorder."""
|
||||
if instance := get_instance(hass):
|
||||
# Wait for db_connected to ensure the recorder instance is created and the
|
||||
# migration flags are set.
|
||||
await hass.data[recorder_helper.DATA_RECORDER].db_connected
|
||||
instance = get_instance(hass)
|
||||
backlog = instance.backlog
|
||||
migration_in_progress = instance.migration_in_progress
|
||||
migration_is_live = instance.migration_is_live
|
||||
@@ -37,13 +41,6 @@ def ws_info(
|
||||
# for the thread state lock which will block the event loop.
|
||||
is_running = instance.is_running
|
||||
max_backlog = instance.max_backlog
|
||||
else:
|
||||
backlog = None
|
||||
migration_in_progress = False
|
||||
migration_is_live = False
|
||||
recording = False
|
||||
is_running = False
|
||||
max_backlog = None
|
||||
|
||||
recorder_info = {
|
||||
"backlog": backlog,
|
||||
|
@@ -50,6 +50,11 @@ STATES_META_SCHEMA_VERSION = 38
|
||||
LAST_REPORTED_SCHEMA_VERSION = 43
|
||||
|
||||
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
|
||||
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
|
||||
# https://github.com/home-assistant/core/pull/120779
|
||||
# fixed the foreign keys in the states table but it did
|
||||
# not bump the schema version which means only databases
|
||||
# created with schema 44 and later do not need the rebuild.
|
||||
|
||||
INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics"
|
||||
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids"
|
||||
|
@@ -8,7 +8,7 @@
|
||||
"quality_scale": "internal",
|
||||
"requirements": [
|
||||
"SQLAlchemy==2.0.38",
|
||||
"fnv-hash-fast==1.2.2",
|
||||
"fnv-hash-fast==1.2.3",
|
||||
"psutil-home-assistant==0.0.1"
|
||||
]
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user