Merge branch 'dev' into Power_Energy_values

This commit is contained in:
EnjoyingM
2025-02-25 01:18:00 +01:00
committed by GitHub
274 changed files with 11242 additions and 2042 deletions

View File

@@ -69,7 +69,7 @@ jobs:
run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T -
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz

View File

@@ -537,7 +537,7 @@ jobs:
python --version python --version
uv pip freeze >> pip_freeze.txt uv pip freeze >> pip_freeze.txt
- name: Upload pip_freeze artifact - name: Upload pip_freeze artifact
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: pip-freeze-${{ matrix.python-version }} name: pip-freeze-${{ matrix.python-version }}
path: pip_freeze.txt path: pip_freeze.txt
@@ -661,7 +661,7 @@ jobs:
. venv/bin/activate . venv/bin/activate
python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json python -m script.licenses extract --output-file=licenses-${{ matrix.python-version }}.json
- name: Upload licenses - name: Upload licenses
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: licenses-${{ github.run_number }}-${{ matrix.python-version }} name: licenses-${{ github.run_number }}-${{ matrix.python-version }}
path: licenses-${{ matrix.python-version }}.json path: licenses-${{ matrix.python-version }}.json
@@ -877,7 +877,7 @@ jobs:
. venv/bin/activate . venv/bin/activate
python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests python -m script.split_tests ${{ needs.info.outputs.test_group_count }} tests
- name: Upload pytest_buckets - name: Upload pytest_buckets
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: pytest_buckets name: pytest_buckets
path: pytest_buckets.txt path: pytest_buckets.txt
@@ -980,14 +980,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-full.conclusion == 'failure' if: success() || failure() && steps.pytest-full.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt path: pytest-*.txt
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml
@@ -1108,7 +1108,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${mariadb}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@@ -1116,7 +1116,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: coverage-${{ matrix.python-version }}-${{ name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.mariadb }} steps.pytest-partial.outputs.mariadb }}
@@ -1239,7 +1239,7 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${postgresql}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@@ -1247,7 +1247,7 @@ jobs:
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: coverage-${{ matrix.python-version }}-${{ name: coverage-${{ matrix.python-version }}-${{
steps.pytest-partial.outputs.postgresql }} steps.pytest-partial.outputs.postgresql }}
@@ -1382,14 +1382,14 @@ jobs:
2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt 2>&1 | tee pytest-${{ matrix.python-version }}-${{ matrix.group }}.txt
- name: Upload pytest output - name: Upload pytest output
if: success() || failure() && steps.pytest-partial.conclusion == 'failure' if: success() || failure() && steps.pytest-partial.conclusion == 'failure'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }} name: pytest-${{ github.run_number }}-${{ matrix.python-version }}-${{ matrix.group }}
path: pytest-*.txt path: pytest-*.txt
overwrite: true overwrite: true
- name: Upload coverage artifact - name: Upload coverage artifact
if: needs.info.outputs.skip_coverage != 'true' if: needs.info.outputs.skip_coverage != 'true'
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: coverage-${{ matrix.python-version }}-${{ matrix.group }} name: coverage-${{ matrix.python-version }}-${{ matrix.group }}
path: coverage.xml path: coverage.xml

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@v4.2.2 uses: actions/checkout@v4.2.2
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v3.28.9 uses: github/codeql-action/init@v3.28.10
with: with:
languages: python languages: python
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3.28.9 uses: github/codeql-action/analyze@v3.28.10
with: with:
category: "/language:python" category: "/language:python"

View File

@@ -91,7 +91,7 @@ jobs:
) > build_constraints.txt ) > build_constraints.txt
- name: Upload env_file - name: Upload env_file
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: env_file name: env_file
path: ./.env_file path: ./.env_file
@@ -99,14 +99,14 @@ jobs:
overwrite: true overwrite: true
- name: Upload build_constraints - name: Upload build_constraints
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: build_constraints name: build_constraints
path: ./build_constraints.txt path: ./build_constraints.txt
overwrite: true overwrite: true
- name: Upload requirements_diff - name: Upload requirements_diff
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: requirements_diff name: requirements_diff
path: ./requirements_diff.txt path: ./requirements_diff.txt
@@ -118,7 +118,7 @@ jobs:
python -m script.gen_requirements_all ci python -m script.gen_requirements_all ci
- name: Upload requirements_all_wheels - name: Upload requirements_all_wheels
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: requirements_all_wheels name: requirements_all_wheels
path: ./requirements_all_wheels_*.txt path: ./requirements_all_wheels_*.txt

View File

@@ -103,6 +103,7 @@ homeassistant.components.auth.*
homeassistant.components.automation.* homeassistant.components.automation.*
homeassistant.components.awair.* homeassistant.components.awair.*
homeassistant.components.axis.* homeassistant.components.axis.*
homeassistant.components.azure_storage.*
homeassistant.components.backup.* homeassistant.components.backup.*
homeassistant.components.baf.* homeassistant.components.baf.*
homeassistant.components.bang_olufsen.* homeassistant.components.bang_olufsen.*
@@ -407,6 +408,7 @@ homeassistant.components.raspberry_pi.*
homeassistant.components.rdw.* homeassistant.components.rdw.*
homeassistant.components.recollect_waste.* homeassistant.components.recollect_waste.*
homeassistant.components.recorder.* homeassistant.components.recorder.*
homeassistant.components.remember_the_milk.*
homeassistant.components.remote.* homeassistant.components.remote.*
homeassistant.components.renault.* homeassistant.components.renault.*
homeassistant.components.reolink.* homeassistant.components.reolink.*

14
CODEOWNERS generated
View File

@@ -180,6 +180,8 @@ build.json @home-assistant/supervisor
/homeassistant/components/azure_event_hub/ @eavanvalkenburg /homeassistant/components/azure_event_hub/ @eavanvalkenburg
/tests/components/azure_event_hub/ @eavanvalkenburg /tests/components/azure_event_hub/ @eavanvalkenburg
/homeassistant/components/azure_service_bus/ @hfurubotten /homeassistant/components/azure_service_bus/ @hfurubotten
/homeassistant/components/azure_storage/ @zweckj
/tests/components/azure_storage/ @zweckj
/homeassistant/components/backup/ @home-assistant/core /homeassistant/components/backup/ @home-assistant/core
/tests/components/backup/ @home-assistant/core /tests/components/backup/ @home-assistant/core
/homeassistant/components/baf/ @bdraco @jfroy /homeassistant/components/baf/ @bdraco @jfroy
@@ -967,8 +969,8 @@ build.json @home-assistant/supervisor
/tests/components/motionblinds_ble/ @LennP @jerrybboy /tests/components/motionblinds_ble/ @LennP @jerrybboy
/homeassistant/components/motioneye/ @dermotduffy /homeassistant/components/motioneye/ @dermotduffy
/tests/components/motioneye/ @dermotduffy /tests/components/motioneye/ @dermotduffy
/homeassistant/components/motionmount/ @RJPoelstra /homeassistant/components/motionmount/ @laiho-vogels
/tests/components/motionmount/ @RJPoelstra /tests/components/motionmount/ @laiho-vogels
/homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco /homeassistant/components/mqtt/ @emontnemery @jbouwh @bdraco
/tests/components/mqtt/ @emontnemery @jbouwh @bdraco /tests/components/mqtt/ @emontnemery @jbouwh @bdraco
/homeassistant/components/msteams/ @peroyvind /homeassistant/components/msteams/ @peroyvind
@@ -1051,8 +1053,8 @@ build.json @home-assistant/supervisor
/tests/components/numato/ @clssn /tests/components/numato/ @clssn
/homeassistant/components/number/ @home-assistant/core @Shulyaka /homeassistant/components/number/ @home-assistant/core @Shulyaka
/tests/components/number/ @home-assistant/core @Shulyaka /tests/components/number/ @home-assistant/core @Shulyaka
/homeassistant/components/nut/ @bdraco @ollo69 @pestevez /homeassistant/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
/tests/components/nut/ @bdraco @ollo69 @pestevez /tests/components/nut/ @bdraco @ollo69 @pestevez @tdfountain
/homeassistant/components/nws/ @MatthewFlamm @kamiyo /homeassistant/components/nws/ @MatthewFlamm @kamiyo
/tests/components/nws/ @MatthewFlamm @kamiyo /tests/components/nws/ @MatthewFlamm @kamiyo
/homeassistant/components/nyt_games/ @joostlek /homeassistant/components/nyt_games/ @joostlek
@@ -1413,6 +1415,8 @@ build.json @home-assistant/supervisor
/tests/components/snapcast/ @luar123 /tests/components/snapcast/ @luar123
/homeassistant/components/snmp/ @nmaggioni /homeassistant/components/snmp/ @nmaggioni
/tests/components/snmp/ @nmaggioni /tests/components/snmp/ @nmaggioni
/homeassistant/components/snoo/ @Lash-L
/tests/components/snoo/ @Lash-L
/homeassistant/components/snooz/ @AustinBrunkhorst /homeassistant/components/snooz/ @AustinBrunkhorst
/tests/components/snooz/ @AustinBrunkhorst /tests/components/snooz/ @AustinBrunkhorst
/homeassistant/components/solaredge/ @frenck @bdraco /homeassistant/components/solaredge/ @frenck @bdraco
@@ -1693,6 +1697,8 @@ build.json @home-assistant/supervisor
/tests/components/weatherflow_cloud/ @jeeftor /tests/components/weatherflow_cloud/ @jeeftor
/homeassistant/components/weatherkit/ @tjhorner /homeassistant/components/weatherkit/ @tjhorner
/tests/components/weatherkit/ @tjhorner /tests/components/weatherkit/ @tjhorner
/homeassistant/components/webdav/ @jpbede
/tests/components/webdav/ @jpbede
/homeassistant/components/webhook/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core
/tests/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core
/homeassistant/components/webmin/ @autinerd /homeassistant/components/webmin/ @autinerd

View File

@@ -328,10 +328,10 @@ async def async_setup_hass(
block_async_io.enable() block_async_io.enable()
if not (recovery_mode := runtime_config.recovery_mode):
config_dict = None config_dict = None
basic_setup_success = False 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) await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
try: try:
@@ -355,12 +355,16 @@ async def async_setup_hass(
hass = await create_hass() hass = await create_hass()
elif not basic_setup_success: 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 recovery_mode = True
await stop_hass(hass) await stop_hass(hass)
hass = await create_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( _LOGGER.warning(
"Detected that %s did not load. Activating recovery mode", "Detected that %s did not load. Activating recovery mode",
",".join(CRITICAL_INTEGRATIONS), ",".join(CRITICAL_INTEGRATIONS),

View File

@@ -6,6 +6,7 @@
"azure_devops", "azure_devops",
"azure_event_hub", "azure_event_hub",
"azure_service_bus", "azure_service_bus",
"azure_storage",
"microsoft_face_detect", "microsoft_face_detect",
"microsoft_face_identify", "microsoft_face_identify",
"microsoft_face", "microsoft_face",

View File

@@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["androidtvremote2"], "loggers": ["androidtvremote2"],
"requirements": ["androidtvremote2==0.1.2"], "requirements": ["androidtvremote2==0.2.0"],
"zeroconf": ["_androidtvremote2._tcp.local."] "zeroconf": ["_androidtvremote2._tcp.local."]
} }

View 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

View 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

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

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

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

View 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

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

View File

@@ -39,6 +39,7 @@ class StoredBackupConfig(TypedDict):
"""Represent the stored backup config.""" """Represent the stored backup config."""
agents: dict[str, StoredAgentConfig] agents: dict[str, StoredAgentConfig]
automatic_backups_configured: bool
create_backup: StoredCreateBackupConfig create_backup: StoredCreateBackupConfig
last_attempted_automatic_backup: str | None last_attempted_automatic_backup: str | None
last_completed_automatic_backup: str | None last_completed_automatic_backup: str | None
@@ -51,6 +52,7 @@ class BackupConfigData:
"""Represent loaded backup config data.""" """Represent loaded backup config data."""
agents: dict[str, AgentConfig] agents: dict[str, AgentConfig]
automatic_backups_configured: bool # only used by frontend
create_backup: CreateBackupConfig create_backup: CreateBackupConfig
last_attempted_automatic_backup: datetime | None = None last_attempted_automatic_backup: datetime | None = None
last_completed_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"]) agent_id: AgentConfig(protected=agent_data["protected"])
for agent_id, agent_data in data["agents"].items() for agent_id, agent_data in data["agents"].items()
}, },
automatic_backups_configured=data["automatic_backups_configured"],
create_backup=CreateBackupConfig( create_backup=CreateBackupConfig(
agent_ids=data["create_backup"]["agent_ids"], agent_ids=data["create_backup"]["agent_ids"],
include_addons=data["create_backup"]["include_addons"], include_addons=data["create_backup"]["include_addons"],
@@ -127,6 +130,7 @@ class BackupConfigData:
agents={ agents={
agent_id: agent.to_dict() for agent_id, agent in self.agents.items() 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(), create_backup=self.create_backup.to_dict(),
last_attempted_automatic_backup=last_attempted, last_attempted_automatic_backup=last_attempted,
last_completed_automatic_backup=last_completed, last_completed_automatic_backup=last_completed,
@@ -142,6 +146,7 @@ class BackupConfig:
"""Initialize backup config.""" """Initialize backup config."""
self.data = BackupConfigData( self.data = BackupConfigData(
agents={}, agents={},
automatic_backups_configured=False,
create_backup=CreateBackupConfig(), create_backup=CreateBackupConfig(),
retention=RetentionConfig(), retention=RetentionConfig(),
schedule=BackupSchedule(), schedule=BackupSchedule(),
@@ -159,6 +164,7 @@ class BackupConfig:
self, self,
*, *,
agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED, agents: dict[str, AgentParametersDict] | UndefinedType = UNDEFINED,
automatic_backups_configured: bool | UndefinedType = UNDEFINED,
create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED, create_backup: CreateBackupParametersDict | UndefinedType = UNDEFINED,
retention: RetentionParametersDict | UndefinedType = UNDEFINED, retention: RetentionParametersDict | UndefinedType = UNDEFINED,
schedule: ScheduleParametersDict | UndefinedType = UNDEFINED, schedule: ScheduleParametersDict | UndefinedType = UNDEFINED,
@@ -172,6 +178,8 @@ class BackupConfig:
self.data.agents[agent_id] = replace( self.data.agents[agent_id] = replace(
self.data.agents[agent_id], **agent_config 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: if create_backup is not UNDEFINED:
self.data.create_backup = replace(self.data.create_backup, **create_backup) self.data.create_backup = replace(self.data.create_backup, **create_backup)
if retention is not UNDEFINED: if retention is not UNDEFINED:

View File

@@ -16,7 +16,7 @@ if TYPE_CHECKING:
STORE_DELAY_SAVE = 30 STORE_DELAY_SAVE = 30
STORAGE_KEY = DOMAIN STORAGE_KEY = DOMAIN
STORAGE_VERSION = 1 STORAGE_VERSION = 1
STORAGE_VERSION_MINOR = 4 STORAGE_VERSION_MINOR = 5
class StoredBackupData(TypedDict): class StoredBackupData(TypedDict):
@@ -67,6 +67,11 @@ class _BackupStore(Store[StoredBackupData]):
data["config"]["retention"]["copies"] = None data["config"]["retention"]["copies"] = None
if data["config"]["retention"]["days"] == 0: if data["config"]["retention"]["days"] == 0:
data["config"]["retention"]["days"] = None 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. # Note: We allow reading data with major version 2.
# Reject if major version is higher than 2. # Reject if major version is higher than 2.

View File

@@ -352,6 +352,7 @@ async def handle_config_info(
{ {
vol.Required("type"): "backup/config/update", vol.Required("type"): "backup/config/update",
vol.Optional("agents"): vol.Schema({str: {"protected": bool}}), vol.Optional("agents"): vol.Schema({str: {"protected": bool}}),
vol.Optional("automatic_backups_configured"): bool,
vol.Optional("create_backup"): vol.Schema( vol.Optional("create_backup"): vol.Schema(
{ {
vol.Optional("agent_ids"): vol.All([str], vol.Unique()), vol.Optional("agent_ids"): vol.All([str], vol.Unique()),

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.4.4", "bluetooth-auto-recovery==1.4.4",
"bluetooth-data-tools==1.23.4", "bluetooth-data-tools==1.23.4",
"dbus-fast==2.33.0", "dbus-fast==2.33.0",
"habluetooth==3.22.1" "habluetooth==3.24.0"
] ]
} }

View File

@@ -104,7 +104,7 @@ class CiscoDeviceScanner(DeviceScanner):
"""Open connection to the router and get arp entries.""" """Open connection to the router and get arp entries."""
try: try:
cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="uft-8") cisco_ssh: pxssh.pxssh[str] = pxssh.pxssh(encoding="utf-8")
cisco_ssh.login( cisco_ssh.login(
self.host, self.host,
self.username, self.username,

View File

@@ -30,10 +30,15 @@ async def async_setup_entry(
async_add_entities( async_add_entities(
[ [
DemoWaterHeater( DemoWaterHeater(
"Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco" "Demo Water Heater", 119, UnitOfTemperature.FAHRENHEIT, False, "eco", 1
), ),
DemoWaterHeater( 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, unit_of_measurement: str,
away: bool, away: bool,
current_operation: str, current_operation: str,
target_temperature_step: float,
) -> None: ) -> None:
"""Initialize the water_heater device.""" """Initialize the water_heater device."""
self._attr_name = name self._attr_name = name
@@ -74,6 +80,7 @@ class DemoWaterHeater(WaterHeaterEntity):
"gas", "gas",
"off", "off",
] ]
self._attr_target_temperature_step = target_temperature_step
def set_temperature(self, **kwargs: Any) -> None: def set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperatures.""" """Set new target temperatures."""

View File

@@ -14,8 +14,8 @@
], ],
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"aiodhcpwatcher==1.1.0", "aiodhcpwatcher==1.1.1",
"aiodiscover==2.6.0", "aiodiscover==2.6.1",
"cached-ipaddress==0.8.0" "cached-ipaddress==0.8.1"
] ]
} }

View File

@@ -25,6 +25,7 @@ import voluptuous as vol
from homeassistant.const import ( from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_ID,
ATTR_MODE,
CONF_PASSWORD, CONF_PASSWORD,
CONF_SCAN_INTERVAL, CONF_SCAN_INTERVAL,
CONF_USERNAME, CONF_USERNAME,
@@ -40,11 +41,10 @@ from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .const import ( from .const import (
ATTR_DURATION_DAYS, ATTR_DURATION,
ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL, ATTR_DURATION_UNTIL,
ATTR_SYSTEM_MODE, ATTR_PERIOD,
ATTR_ZONE_TEMP, ATTR_SETPOINT,
CONF_LOCATION_IDX, CONF_LOCATION_IDX,
DOMAIN, DOMAIN,
SCAN_INTERVAL_DEFAULT, SCAN_INTERVAL_DEFAULT,
@@ -81,7 +81,7 @@ RESET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema( SET_ZONE_OVERRIDE_SCHEMA: Final = vol.Schema(
{ {
vol.Required(ATTR_ENTITY_ID): cv.entity_id, 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.Coerce(float), vol.Range(min=4.0, max=35.0)
), ),
vol.Optional(ATTR_DURATION_UNTIL): vol.All( vol.Optional(ATTR_DURATION_UNTIL): vol.All(
@@ -222,7 +222,7 @@ def setup_service_functions(
# Permanent-only modes will use this schema # Permanent-only modes will use this schema
perm_modes = [m[SZ_SYSTEM_MODE] for m in modes if not m[SZ_CAN_BE_TEMPORARY]] 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 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) system_mode_schemas.append(schema)
modes = [m for m in modes if m[SZ_CAN_BE_TEMPORARY]] 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 if temp_modes: # any of: "AutoWithEco", permanent or for 0-24 hours
schema = vol.Schema( schema = vol.Schema(
{ {
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), vol.Required(ATTR_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION_HOURS): vol.All( vol.Optional(ATTR_DURATION): vol.All(
cv.time_period, cv.time_period,
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)), 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 if temp_modes: # any of: "Away", "Custom", "DayOff", permanent or for 1-99 days
schema = vol.Schema( schema = vol.Schema(
{ {
vol.Required(ATTR_SYSTEM_MODE): vol.In(temp_modes), vol.Required(ATTR_MODE): vol.In(temp_modes),
vol.Optional(ATTR_DURATION_DAYS): vol.All( vol.Optional(ATTR_PERIOD): vol.All(
cv.time_period, cv.time_period,
vol.Range(min=timedelta(days=1), max=timedelta(days=99)), vol.Range(min=timedelta(days=1), max=timedelta(days=99)),
), ),

View File

@@ -29,7 +29,7 @@ from homeassistant.components.climate import (
ClimateEntityFeature, ClimateEntityFeature,
HVACMode, 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.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
@@ -38,11 +38,10 @@ from homeassistant.util import dt as dt_util
from . import EVOHOME_KEY from . import EVOHOME_KEY
from .const import ( from .const import (
ATTR_DURATION_DAYS, ATTR_DURATION,
ATTR_DURATION_HOURS,
ATTR_DURATION_UNTIL, ATTR_DURATION_UNTIL,
ATTR_SYSTEM_MODE, ATTR_PERIOD,
ATTR_ZONE_TEMP, ATTR_SETPOINT,
EvoService, EvoService,
) )
from .coordinator import EvoDataUpdateCoordinator from .coordinator import EvoDataUpdateCoordinator
@@ -180,7 +179,7 @@ class EvoZone(EvoChild, EvoClimateEntity):
return return
# otherwise it is EvoService.SET_ZONE_OVERRIDE # 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: if ATTR_DURATION_UNTIL in data:
duration: timedelta = data[ATTR_DURATION_UNTIL] duration: timedelta = data[ATTR_DURATION_UNTIL]
@@ -349,16 +348,16 @@ class EvoController(EvoClimateEntity):
Data validation is not required, it will have been done upstream. Data validation is not required, it will have been done upstream.
""" """
if service == EvoService.SET_SYSTEM_MODE: if service == EvoService.SET_SYSTEM_MODE:
mode = data[ATTR_SYSTEM_MODE] mode = data[ATTR_MODE]
else: # otherwise it is EvoService.RESET_SYSTEM else: # otherwise it is EvoService.RESET_SYSTEM
mode = EvoSystemMode.AUTO_WITH_RESET mode = EvoSystemMode.AUTO_WITH_RESET
if ATTR_DURATION_DAYS in data: if ATTR_PERIOD in data:
until = dt_util.start_of_local_day() until = dt_util.start_of_local_day()
until += data[ATTR_DURATION_DAYS] until += data[ATTR_PERIOD]
elif ATTR_DURATION_HOURS in data: elif ATTR_DURATION in data:
until = dt_util.now() + data[ATTR_DURATION_HOURS] until = dt_util.now() + data[ATTR_DURATION]
else: else:
until = None until = None

View File

@@ -18,11 +18,10 @@ USER_DATA: Final = "user_data"
SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300) SCAN_INTERVAL_DEFAULT: Final = timedelta(seconds=300)
SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60) SCAN_INTERVAL_MINIMUM: Final = timedelta(seconds=60)
ATTR_SYSTEM_MODE: Final = "mode" ATTR_PERIOD: Final = "period" # number of days
ATTR_DURATION_DAYS: Final = "period" ATTR_DURATION: Final = "duration" # number of minutes, <24h
ATTR_DURATION_HOURS: Final = "duration"
ATTR_ZONE_TEMP: Final = "setpoint" ATTR_SETPOINT: Final = "setpoint"
ATTR_DURATION_UNTIL: Final = "duration" ATTR_DURATION_UNTIL: Final = "duration"

View File

@@ -7,7 +7,7 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyfritzhome"], "loggers": ["pyfritzhome"],
"requirements": ["pyfritzhome==0.6.15"], "requirements": ["pyfritzhome==0.6.17"],
"ssdp": [ "ssdp": [
{ {
"st": "urn:schemas-upnp-org:device:fritzbox:1" "st": "urn:schemas-upnp-org:device:fritzbox:1"

View File

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

View File

@@ -7,7 +7,7 @@ from collections.abc import Callable
from google_drive_api.exceptions import GoogleDriveApiError from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession 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: except GoogleDriveApiError as err:
raise ConfigEntryNotReady from 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 return True
@@ -58,15 +62,4 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleDriveConfigEntry hass: HomeAssistant, entry: GoogleDriveConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
_async_notify_backup_listeners_soon(hass)
return True 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)

View File

@@ -2,14 +2,11 @@
from __future__ import annotations from __future__ import annotations
import mimetypes
from pathlib import Path from pathlib import Path
from google.ai import generativelanguage_v1beta from google import genai # type: ignore[attr-defined]
from google.api_core.client_options import ClientOptions from google.genai.errors import APIError, ClientError
from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPIError from requests.exceptions import Timeout
import google.generativeai as genai
import google.generativeai.types as genai_types
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -27,59 +24,86 @@ from homeassistant.exceptions import (
HomeAssistantError, HomeAssistantError,
) )
from homeassistant.helpers import config_validation as cv 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 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" SERVICE_GENERATE_CONTENT = "generate_content"
CONF_IMAGE_FILENAME = "image_filename" CONF_IMAGE_FILENAME = "image_filename"
CONF_FILENAMES = "filenames"
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = (Platform.CONVERSATION,) PLATFORMS = (Platform.CONVERSATION,)
type GoogleGenerativeAIConfigEntry = ConfigEntry[genai.Client]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Google Generative AI Conversation.""" """Set up Google Generative AI Conversation."""
async def generate_content(call: ServiceCall) -> ServiceResponse: async def generate_content(call: ServiceCall) -> ServiceResponse:
"""Generate content from text and optionally images.""" """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]] 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] image_filenames = call.data[CONF_IMAGE_FILENAME]
for image_filename in image_filenames: filenames = call.data[CONF_FILENAMES]
if not hass.config.is_allowed_path(image_filename): for filename in set(image_filenames + filenames):
if not hass.config.is_allowed_path(filename):
raise HomeAssistantError( 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 " "`allowlist_external_dirs` may need to be adjusted in "
"`configuration.yaml`" "`configuration.yaml`"
) )
if not Path(image_filename).exists(): if not Path(filename).exists():
raise HomeAssistantError(f"`{image_filename}` does not exist") raise HomeAssistantError(f"`{filename}` does not exist")
mime_type, _ = mimetypes.guess_type(image_filename) prompt_parts.append(client.files.upload(file=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
),
}
)
model = genai.GenerativeModel(model_name=RECOMMENDED_CHAT_MODEL) await hass.async_add_executor_job(append_files_to_prompt)
try: try:
response = await model.generate_content_async(prompt_parts) response = await client.aio.models.generate_content(
model=RECOMMENDED_CHAT_MODEL, contents=prompt_parts
)
except ( except (
GoogleAPIError, APIError,
ValueError, ValueError,
genai_types.BlockedPromptException,
genai_types.StopCandidateException,
) as err: ) as err:
raise HomeAssistantError(f"Error generating content: {err}") from err raise HomeAssistantError(f"Error generating content: {err}") from err
if not response.parts: if response.prompt_feedback:
raise HomeAssistantError("Error generating content") 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} 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( vol.Optional(CONF_IMAGE_FILENAME, default=[]): vol.All(
cv.ensure_list, [cv.string] cv.ensure_list, [cv.string]
), ),
vol.Optional(CONF_FILENAMES, default=[]): vol.All(
cv.ensure_list, [cv.string]
),
} }
), ),
supports_response=SupportsResponse.ONLY, supports_response=SupportsResponse.ONLY,
@@ -100,30 +127,34 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True 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.""" """Set up Google Generative AI Conversation from a config entry."""
genai.configure(api_key=entry.data[CONF_API_KEY])
try: try:
client = generativelanguage_v1beta.ModelServiceAsyncClient( client = genai.Client(api_key=entry.data[CONF_API_KEY])
client_options=ClientOptions(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( except (APIError, Timeout) as err:
name=entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), timeout=5.0 if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
) raise ConfigEntryAuthFailed(err.message) from err
except (GoogleAPIError, ValueError) as err: if isinstance(err, Timeout):
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID":
raise ConfigEntryAuthFailed(err) from err
if isinstance(err, DeadlineExceeded):
raise ConfigEntryNotReady(err) from err raise ConfigEntryNotReady(err) from err
raise ConfigEntryError(err) from err raise ConfigEntryError(err) from err
else:
entry.runtime_data = client
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(
hass: HomeAssistant, entry: GoogleGenerativeAIConfigEntry
) -> bool:
"""Unload GoogleGenerativeAI.""" """Unload GoogleGenerativeAI."""
if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
return False return False

View File

@@ -3,15 +3,13 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Mapping from collections.abc import Mapping
from functools import partial
import logging import logging
from types import MappingProxyType from types import MappingProxyType
from typing import Any from typing import Any
from google.ai import generativelanguage_v1beta from google import genai # type: ignore[attr-defined]
from google.api_core.client_options import ClientOptions from google.genai.errors import APIError, ClientError
from google.api_core.exceptions import ClientError, GoogleAPIError from requests.exceptions import Timeout
import google.generativeai as genai
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ( from homeassistant.config_entries import (
@@ -53,6 +51,7 @@ from .const import (
RECOMMENDED_TEMPERATURE, RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_K, RECOMMENDED_TOP_K,
RECOMMENDED_TOP_P, RECOMMENDED_TOP_P,
TIMEOUT_MILLIS,
) )
_LOGGER = logging.getLogger(__name__) _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. """Validate the user input allows us to connect.
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
""" """
client = generativelanguage_v1beta.ModelServiceAsyncClient( client = genai.Client(api_key=data[CONF_API_KEY])
client_options=ClientOptions(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): class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -93,9 +97,9 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN):
errors: dict[str, str] = {} errors: dict[str, str] = {}
if user_input is not None: if user_input is not None:
try: try:
await validate_input(self.hass, user_input) await validate_input(user_input)
except GoogleAPIError as err: except (APIError, Timeout) as err:
if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": if isinstance(err, ClientError) and "API_KEY_INVALID" in str(err):
errors["base"] = "invalid_auth" errors["base"] = "invalid_auth"
else: else:
errors["base"] = "cannot_connect" errors["base"] = "cannot_connect"
@@ -166,6 +170,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
self.last_rendered_recommended = config_entry.options.get( self.last_rendered_recommended = config_entry.options.get(
CONF_RECOMMENDED, False CONF_RECOMMENDED, False
) )
self._genai_client = config_entry.runtime_data
async def async_step_init( async def async_step_init(
self, user_input: dict[str, Any] | None = None 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], 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema(schema), data_schema=vol.Schema(schema),
@@ -198,6 +205,7 @@ class GoogleGenerativeAIOptionsFlow(OptionsFlow):
async def google_generative_ai_config_option_schema( async def google_generative_ai_config_option_schema(
hass: HomeAssistant, hass: HomeAssistant,
options: dict[str, Any] | MappingProxyType[str, Any], options: dict[str, Any] | MappingProxyType[str, Any],
genai_client: genai.Client,
) -> dict: ) -> dict:
"""Return a schema for Google Generative AI completion options.""" """Return a schema for Google Generative AI completion options."""
hass_apis: list[SelectOptionDict] = [ hass_apis: list[SelectOptionDict] = [
@@ -236,18 +244,21 @@ async def google_generative_ai_config_option_schema(
if options.get(CONF_RECOMMENDED): if options.get(CONF_RECOMMENDED):
return schema 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 = [ models = [
SelectOptionDict( SelectOptionDict(
label=api_model.display_name, label=api_model.display_name,
value=api_model.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 ( if (
api_model.name != "models/gemini-1.0-pro" # duplicate of gemini-pro 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 "vision" not in api_model.name
and "generateContent" in api_model.supported_generation_methods and "generateContent" in api_model.supported_actions
) )
] ]

View File

@@ -22,3 +22,5 @@ CONF_HATE_BLOCK_THRESHOLD = "hate_block_threshold"
CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold" CONF_SEXUAL_BLOCK_THRESHOLD = "sexual_block_threshold"
CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold" CONF_DANGEROUS_BLOCK_THRESHOLD = "dangerous_block_threshold"
RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE" RECOMMENDED_HARM_BLOCK_THRESHOLD = "BLOCK_MEDIUM_AND_ABOVE"
TIMEOUT_MILLIS = 10000

View File

@@ -6,11 +6,18 @@ import codecs
from collections.abc import Callable from collections.abc import Callable
from typing import Any, Literal, cast from typing import Any, Literal, cast
from google.api_core.exceptions import GoogleAPIError from google.genai.errors import APIError
import google.generativeai as genai from google.genai.types import (
from google.generativeai import protos AutomaticFunctionCallingConfig,
import google.generativeai.types as genai_types Content,
from google.protobuf.json_format import MessageToDict FunctionDeclaration,
GenerateContentConfig,
HarmCategory,
Part,
SafetySetting,
Schema,
Tool,
)
from voluptuous_openapi import convert from voluptuous_openapi import convert
from homeassistant.components import assist_pipeline, conversation from homeassistant.components import assist_pipeline, conversation
@@ -57,21 +64,40 @@ async def async_setup_entry(
SUPPORTED_SCHEMA_KEYS = { SUPPORTED_SCHEMA_KEYS = {
"type", "min_items",
"format", "example",
"description", "property_ordering",
"pattern",
"minimum",
"default",
"any_of",
"max_length",
"title",
"min_properties",
"min_length",
"max_items",
"maximum",
"nullable", "nullable",
"max_properties",
"type",
"description",
"enum", "enum",
"format",
"items", "items",
"properties", "properties",
"required", "required",
} }
def _format_schema(schema: dict[str, Any]) -> dict[str, Any]: def _camel_to_snake(name: str) -> str:
"""Format the schema to protobuf.""" """Convert camel case to snake case."""
if (subschemas := schema.get("anyOf")) or (subschemas := schema.get("allOf")): return "".join(["_" + c.lower() if c.isupper() else c for c in name]).lstrip("_")
for subschema in subschemas: # Gemini API does not support anyOf and allOf keys
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 if "type" in subschema: # Fallback to first subschema with 'type' field
return _format_schema(subschema) return _format_schema(subschema)
return _format_schema( return _format_schema(
@@ -80,42 +106,38 @@ def _format_schema(schema: dict[str, Any]) -> dict[str, Any]:
result = {} result = {}
for key, val in schema.items(): for key, val in schema.items():
key = _camel_to_snake(key)
if key not in SUPPORTED_SCHEMA_KEYS: if key not in SUPPORTED_SCHEMA_KEYS:
continue continue
if key == "any_of":
val = [_format_schema(subschema) for subschema in val]
if key == "type": if key == "type":
key = "type_"
val = val.upper() val = val.upper()
elif key == "format": if key == "items":
if schema.get("type") == "string" and val != "enum":
continue
if schema.get("type") not in ("number", "integer", "string"):
continue
key = "format_"
elif key == "items":
val = _format_schema(val) val = _format_schema(val)
elif key == "properties": elif key == "properties":
val = {k: _format_schema(v) for k, v in val.items()} val = {k: _format_schema(v) for k, v in val.items()}
result[key] = val 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 # enum is only allowed for STRING type. This is safe as long as the schema
# contains vol.Coerce for the respective type, for example: # contains vol.Coerce for the respective type, for example:
# vol.All(vol.Coerce(int), vol.In([1, 2, 3])) # 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"]] 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. # 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, # 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. # but we don't have a better fallback strategy so far.
result["properties"] = {"json": {"type_": "STRING"}} result["properties"] = {"json": {"type": "STRING"}}
result["required"] = [] result["required"] = []
return result return cast(Schema, result)
def _format_tool( def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> dict[str, Any]: ) -> Tool:
"""Format tool specification.""" """Format tool specification."""
if tool.parameters.schema: if tool.parameters.schema:
@@ -125,16 +147,14 @@ def _format_tool(
else: else:
parameters = None parameters = None
return protos.Tool( return Tool(
{ function_declarations=[
"function_declarations": [ FunctionDeclaration(
{ name=tool.name,
"name": tool.name, description=tool.description,
"description": tool.description, parameters=parameters,
"parameters": parameters, )
}
] ]
}
) )
@@ -151,15 +171,13 @@ def _escape_decode(value: Any) -> Any:
def _create_google_tool_response_content( def _create_google_tool_response_content(
content: list[conversation.ToolResultContent], content: list[conversation.ToolResultContent],
) -> protos.Content: ) -> Content:
"""Create a Google tool response content.""" """Create a Google tool response content."""
return protos.Content( return Content(
parts=[ parts=[
protos.Part( Part.from_function_response(
function_response=protos.FunctionResponse(
name=tool_result.tool_name, response=tool_result.tool_result name=tool_result.tool_name, response=tool_result.tool_result
) )
)
for tool_result in content for tool_result in content
] ]
) )
@@ -169,33 +187,36 @@ def _convert_content(
content: conversation.UserContent content: conversation.UserContent
| conversation.AssistantContent | conversation.AssistantContent
| conversation.SystemContent, | conversation.SystemContent,
) -> genai_types.ContentDict: ) -> Content:
"""Convert HA content to Google content.""" """Convert HA content to Google content."""
if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr] if content.role != "assistant" or not content.tool_calls: # type: ignore[union-attr]
role = "model" if content.role == "assistant" else content.role 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. # Handle the Assistant content with tool calls.
assert type(content) is conversation.AssistantContent assert type(content) is conversation.AssistantContent
parts = [] parts: list[Part] = []
if content.content: if content.content:
parts.append(protos.Part(text=content.content)) parts.append(Part.from_text(text=content.content))
if content.tool_calls: if content.tool_calls:
parts.extend( parts.extend(
[ [
protos.Part( Part.from_function_call(
function_call=protos.FunctionCall(
name=tool_call.tool_name, name=tool_call.tool_name,
args=_escape_decode(tool_call.tool_args), args=_escape_decode(tool_call.tool_args),
) )
)
for tool_call in content.tool_calls for tool_call in content.tool_calls
] ]
) )
return protos.Content({"role": "model", "parts": parts}) return Content(role="model", parts=parts)
class GoogleGenerativeAIConversationEntity( class GoogleGenerativeAIConversationEntity(
@@ -209,6 +230,7 @@ class GoogleGenerativeAIConversationEntity(
def __init__(self, entry: ConfigEntry) -> None: def __init__(self, entry: ConfigEntry) -> None:
"""Initialize the agent.""" """Initialize the agent."""
self.entry = entry self.entry = entry
self._genai_client = entry.runtime_data
self._attr_unique_id = entry.entry_id self._attr_unique_id = entry.entry_id
self._attr_device_info = dr.DeviceInfo( self._attr_device_info = dr.DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)}, identifiers={(DOMAIN, entry.entry_id)},
@@ -273,7 +295,7 @@ class GoogleGenerativeAIConversationEntity(
except conversation.ConverseError as err: except conversation.ConverseError as err:
return err.as_conversation_result() return err.as_conversation_result()
tools: list[dict[str, Any]] | None = None tools: list[Tool | Callable[..., Any]] | None = None
if chat_log.llm_api: if chat_log.llm_api:
tools = [ tools = [
_format_tool(tool, chat_log.llm_api.custom_serializer) _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 "gemini-1.0" not in model_name and "gemini-pro" not in model_name
) )
prompt = chat_log.content[0].content # type: ignore[union-attr] prompt_content = cast(
messages: list[genai_types.ContentDict] = [] 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. # Google groups tool results, we do not. Group them before sending.
tool_results: list[conversation.ToolResultContent] = [] 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": if chat_content.role == "tool_result":
# mypy doesn't like picking a type based on checking shared property 'role' # mypy doesn't like picking a type based on checking shared property 'role'
tool_results.append(cast(conversation.ToolResultContent, chat_content)) tool_results.append(cast(conversation.ToolResultContent, chat_content))
@@ -317,85 +348,93 @@ class GoogleGenerativeAIConversationEntity(
if tool_results: if tool_results:
messages.append(_create_google_tool_response_content(tool_results)) messages.append(_create_google_tool_response_content(tool_results))
generateContentConfig = GenerateContentConfig(
model = genai.GenerativeModel( temperature=self.entry.options.get(
model_name=model_name,
generation_config={
"temperature": self.entry.options.get(
CONF_TEMPERATURE, RECOMMENDED_TEMPERATURE 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),
"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( max_output_tokens=self.entry.options.get(
CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS CONF_MAX_TOKENS, RECOMMENDED_MAX_TOKENS
), ),
}, safety_settings=[
safety_settings={ SafetySetting(
"HARASSMENT": self.entry.options.get( category=HarmCategory.HARM_CATEGORY_HATE_SPEECH,
CONF_HARASSMENT_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD threshold=self.entry.options.get(
),
"HATE": self.entry.options.get(
CONF_HATE_BLOCK_THRESHOLD, RECOMMENDED_HARM_BLOCK_THRESHOLD 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 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, tools=tools or None,
system_instruction=prompt if supports_system_instruction else 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: if not supports_system_instruction:
messages = [ messages = [
{"role": "user", "parts": prompt}, Content(role="user", parts=[Part.from_text(text=prompt)]),
{"role": "model", "parts": "Ok"}, Content(role="model", parts=[Part.from_text(text="Ok")]),
*messages, *messages,
] ]
chat = self._genai_client.aio.chats.create(
chat = model.start_chat(history=messages) model=model_name, history=messages, config=generateContentConfig
chat_request = user_input.text )
chat_request: str | Content = user_input.text
# To prevent infinite loops, we limit the number of iterations # To prevent infinite loops, we limit the number of iterations
for _iteration in range(MAX_TOOL_ITERATIONS): for _iteration in range(MAX_TOOL_ITERATIONS):
try: try:
chat_response = await chat.send_message_async(chat_request) chat_response = await chat.send_message(message=chat_request)
except (
GoogleAPIError,
ValueError,
genai_types.BlockedPromptException,
genai_types.StopCandidateException,
) as err:
LOGGER.error("Error sending message: %s %s", type(err), err)
if isinstance( if chat_response.prompt_feedback:
err, genai_types.StopCandidateException raise HomeAssistantError(
) and "finish_reason: SAFETY\n" in str(err): f"The message got blocked due to content violations, reason: {chat_response.prompt_feedback.block_reason_message}"
error = "The message got blocked by your safety settings"
else:
error = (
f"Sorry, I had a problem talking to Google Generative AI: {err}"
) )
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 raise HomeAssistantError(error) from err
LOGGER.debug("Response: %s", chat_response.parts) response_parts = chat_response.candidates[0].content.parts
if not chat_response.parts: if not response_parts:
raise HomeAssistantError( raise HomeAssistantError(
"Sorry, I had a problem getting a response from Google Generative AI." "Sorry, I had a problem getting a response from Google Generative AI."
) )
content = " ".join( 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 = [] tool_calls = []
for part in chat_response.parts: for part in response_parts:
if not part.function_call: if not part.function_call:
continue continue
tool_call = MessageToDict(part.function_call._pb) # noqa: SLF001 tool_call = part.function_call
tool_name = tool_call["name"] tool_name = tool_call.name
tool_args = _escape_decode(tool_call["args"]) tool_args = _escape_decode(tool_call.args)
tool_calls.append( tool_calls.append(
llm.ToolInput(tool_name=tool_name, tool_args=tool_args) llm.ToolInput(tool_name=tool_name, tool_args=tool_args)
) )
@@ -418,7 +457,7 @@ class GoogleGenerativeAIConversationEntity(
response = intent.IntentResponse(language=user_input.language) response = intent.IntentResponse(language=user_input.language)
response.async_set_speech( response.async_set_speech(
" ".join([part.text.strip() for part in chat_response.parts if part.text]) " ".join([part.text.strip() for part in response_parts if part.text])
) )
return conversation.ConversationResult( return conversation.ConversationResult(
response=response, conversation_id=chat_log.conversation_id response=response, conversation_id=chat_log.conversation_id

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation",
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["google-generativeai==0.8.2"] "requirements": ["google-genai==1.1.0"]
} }

View File

@@ -9,3 +9,8 @@ generate_content:
required: false required: false
selector: selector:
object: object:
filenames:
required: false
selector:
text:
multiple: true

View File

@@ -56,10 +56,21 @@
}, },
"image_filename": { "image_filename": {
"name": "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" "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."
}
} }
} }

View File

@@ -117,20 +117,24 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
"""Move an item in the To-do list.""" """Move an item in the To-do list."""
if TYPE_CHECKING: if TYPE_CHECKING:
assert self.todo_items 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: if previous_uid:
pos = self.todo_items.index( pos = tasks_order.index(UUID(previous_uid))
next(item for item in self.todo_items if item.uid == previous_uid) if pos < tasks_order.index(UUID(uid)):
)
if pos < self.todo_items.index(
next(item for item in self.todo_items if item.uid == uid)
):
pos += 1 pos += 1
else: else:
pos = 0 pos = 0
try: try:
tasks_order[:] = (
await self.coordinator.habitica.reorder_task(UUID(uid), pos) await self.coordinator.habitica.reorder_task(UUID(uid), pos)
).data
except TooManyRequestsError as e: except TooManyRequestsError as e:
raise HomeAssistantError( raise HomeAssistantError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
@@ -144,20 +148,6 @@ class BaseHabiticaListEntity(HabiticaBase, TodoListEntity):
translation_key=f"move_{self.entity_description.key}_item_failed", translation_key=f"move_{self.entity_description.key}_item_failed",
translation_placeholders={"pos": str(pos)}, translation_placeholders={"pos": str(pos)},
) from e ) 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: async def async_update_todo_item(self, item: TodoItem) -> None:
"""Update a Habitica todo.""" """Update a Habitica todo."""
@@ -271,7 +261,7 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
def todo_items(self) -> list[TodoItem]: def todo_items(self) -> list[TodoItem]:
"""Return the todo items.""" """Return the todo items."""
return [ tasks = [
*( *(
TodoItem( TodoItem(
uid=str(task.id), uid=str(task.id),
@@ -288,6 +278,15 @@ class HabiticaTodosListEntity(BaseHabiticaListEntity):
if task.Type is TaskType.TODO 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: async def async_create_todo_item(self, item: TodoItem) -> None:
"""Create a Habitica todo.""" """Create a Habitica todo."""
@@ -348,7 +347,7 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
if TYPE_CHECKING: if TYPE_CHECKING:
assert self.coordinator.data.user.lastCron assert self.coordinator.data.user.lastCron
return [ tasks = [
*( *(
TodoItem( TodoItem(
uid=str(task.id), uid=str(task.id),
@@ -365,3 +364,12 @@ class HabiticaDailiesListEntity(BaseHabiticaListEntity):
if task.Type is TaskType.DAILY 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)
),
)

View File

@@ -7,7 +7,7 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyheos"], "loggers": ["pyheos"],
"quality_scale": "silver", "quality_scale": "platinum",
"requirements": ["pyheos==1.0.2"], "requirements": ["pyheos==1.0.2"],
"ssdp": [ "ssdp": [
{ {

View File

@@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/holiday", "documentation": "https://www.home-assistant.io/integrations/holiday",
"iot_class": "local_polling", "iot_class": "local_polling",
"requirements": ["holidays==0.66", "babel==2.15.0"] "requirements": ["holidays==0.67", "babel==2.15.0"]
} }

View File

@@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT, Platform.LIGHT,
Platform.NUMBER, Platform.NUMBER,
Platform.SELECT, Platform.SELECT,
@@ -212,7 +213,11 @@ async def _get_client_and_ha_id(
break break
if entry is None: if entry is None:
raise ServiceValidationError( 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( ha_id = next(
@@ -404,6 +409,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
"""Execute calls to services executing a command.""" """Execute calls to services executing a command."""
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID]) 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: try:
await client.put_command(ha_id, command_key=command_key, value=True) await client.put_command(ha_id, command_key=command_key, value=True)
except HomeConnectError as err: except HomeConnectError as err:
@@ -609,6 +625,7 @@ async def async_unload_entry(
) -> bool: ) -> bool:
"""Unload a config entry.""" """Unload a config entry."""
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions") 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) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View 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

View File

@@ -1,5 +1,6 @@
"""Common callbacks for all Home Connect platforms.""" """Common callbacks for all Home Connect platforms."""
from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from functools import partial from functools import partial
from typing import cast from typing import cast
@@ -9,7 +10,32 @@ from aiohomeconnect.model import EventKey
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry 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( def _handle_paired_or_connected_appliance(
@@ -18,6 +44,12 @@ def _handle_paired_or_connected_appliance(
get_entities_for_appliance: Callable[ get_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] [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, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Handle a new paired appliance or an appliance that has been connected. """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) for entity in get_entities_for_appliance(entry, appliance)
if entity.unique_id not in known_entity_unique_ids 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( known_entity_unique_ids.update(
{ {
cast(str, entity.unique_id): appliance.info.ha_id cast(str, entity.unique_id): appliance.info.ha_id
@@ -47,11 +101,17 @@ def _handle_paired_or_connected_appliance(
def _handle_depaired_appliance( def _handle_depaired_appliance(
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
known_entity_unique_ids: dict[str, str], known_entity_unique_ids: dict[str, str],
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
) -> None: ) -> None:
"""Handle a removed appliance.""" """Handle a removed appliance."""
for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items(): for entity_unique_id, appliance_id in known_entity_unique_ids.copy().items():
if appliance_id not in entry.runtime_data.data: if appliance_id not in entry.runtime_data.data:
known_entity_unique_ids.pop(entity_unique_id, None) 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( def setup_home_connect_entry(
@@ -60,13 +120,44 @@ def setup_home_connect_entry(
[HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity] [HomeConnectConfigEntry, HomeConnectApplianceData], list[HomeConnectEntity]
], ],
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectConfigEntry, HomeConnectApplianceData],
list[HomeConnectOptionEntity],
]
| None = None,
) -> None: ) -> None:
"""Set up the callbacks for paired and depaired appliances.""" """Set up the callbacks for paired and depaired appliances."""
known_entity_unique_ids: dict[str, str] = {} known_entity_unique_ids: dict[str, str] = {}
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]] = (
defaultdict(list)
)
entities: list[HomeConnectEntity] = [] entities: list[HomeConnectEntity] = []
for appliance in entry.runtime_data.data.values(): for appliance in entry.runtime_data.data.values():
entities_to_add = get_entities_for_appliance(entry, appliance) 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( known_entity_unique_ids.update(
{ {
cast(str, entity.unique_id): appliance.info.ha_id cast(str, entity.unique_id): appliance.info.ha_id
@@ -83,6 +174,8 @@ def setup_home_connect_entry(
entry, entry,
known_entity_unique_ids, known_entity_unique_ids,
get_entities_for_appliance, get_entities_for_appliance,
get_option_entities_for_appliance,
changed_options_listener_remove_callbacks,
async_add_entities, async_add_entities,
), ),
( (
@@ -93,7 +186,12 @@ def setup_home_connect_entry(
) )
entry.async_on_unload( entry.async_on_unload(
entry.runtime_data.async_add_special_listener( 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,), (EventKey.BSH_COMMON_APPLIANCE_DEPAIRED,),
) )
) )

View File

@@ -87,7 +87,7 @@ PROGRAMS_TRANSLATION_KEYS_MAP = {
value: key for key, value in TRANSLATION_KEYS_PROGRAMS_MAP.items() 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 bsh_key_to_translation_key(option): option
for option in ( for option in (
"ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap", "ConsumerProducts.CleaningRobot.EnumType.AvailableMaps.TempMap",
@@ -305,7 +305,7 @@ PROGRAM_ENUM_OPTIONS = {
for option_key, options in ( for option_key, options in (
( (
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID, OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
REFERENCE_MAP_ID_OPTIONS, AVAILABLE_MAPS_ENUM,
), ),
( (
OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE, OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,

View File

@@ -7,16 +7,19 @@ from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
from typing import Any from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import ( from aiohomeconnect.model import (
CommandKey,
Event, Event,
EventKey, EventKey,
EventMessage, EventMessage,
EventType, EventType,
GetSetting, GetSetting,
HomeAppliance, HomeAppliance,
OptionKey,
ProgramKey,
SettingKey, SettingKey,
Status, Status,
StatusKey, StatusKey,
@@ -28,7 +31,7 @@ from aiohomeconnect.model.error import (
HomeConnectRequestError, HomeConnectRequestError,
UnauthorizedError, UnauthorizedError,
) )
from aiohomeconnect.model.program import EnumerateProgram from aiohomeconnect.model.program import EnumerateProgram, ProgramDefinitionOption
from propcache.api import cached_property from propcache.api import cached_property
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@@ -51,16 +54,21 @@ EVENT_STREAM_RECONNECT_DELAY = 30
class HomeConnectApplianceData: class HomeConnectApplianceData:
"""Class to hold Home Connect appliance data.""" """Class to hold Home Connect appliance data."""
commands: set[CommandKey]
events: dict[EventKey, Event] events: dict[EventKey, Event]
info: HomeAppliance info: HomeAppliance
options: dict[OptionKey, ProgramDefinitionOption]
programs: list[EnumerateProgram] programs: list[EnumerateProgram]
settings: dict[SettingKey, GetSetting] settings: dict[SettingKey, GetSetting]
status: dict[StatusKey, Status] status: dict[StatusKey, Status]
def update(self, other: HomeConnectApplianceData) -> None: def update(self, other: HomeConnectApplianceData) -> None:
"""Update data with data from other instance.""" """Update data with data from other instance."""
self.commands.update(other.commands)
self.events.update(other.events) self.events.update(other.events)
self.info.connected = other.info.connected self.info.connected = other.info.connected
self.options.clear()
self.options.update(other.options)
self.programs.clear() self.programs.clear()
self.programs.extend(other.programs) self.programs.extend(other.programs)
self.settings.update(other.settings) self.settings.update(other.settings)
@@ -172,8 +180,9 @@ class HomeConnectCoordinator(
settings = self.data[event_message_ha_id].settings settings = self.data[event_message_ha_id].settings
events = self.data[event_message_ha_id].events events = self.data[event_message_ha_id].events
for event in event_message.data.items: for event in event_message.data.items:
if event.key in SettingKey: event_key = event.key
setting_key = SettingKey(event.key) if event_key in SettingKey:
setting_key = SettingKey(event_key)
if setting_key in settings: if setting_key in settings:
settings[setting_key].value = event.value settings[setting_key].value = event.value
else: else:
@@ -183,7 +192,16 @@ class HomeConnectCoordinator(
value=event.value, value=event.value,
) )
else: 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) self._call_event_listener(event_message)
case EventType.EVENT: case EventType.EVENT:
@@ -338,6 +356,7 @@ class HomeConnectCoordinator(
programs = [] programs = []
events = {} events = {}
options = {}
if appliance.type in APPLIANCES_WITH_PROGRAMS: if appliance.type in APPLIANCES_WITH_PROGRAMS:
try: try:
all_programs = await self.client.get_all_programs(appliance.ha_id) all_programs = await self.client.get_all_programs(appliance.ha_id)
@@ -351,15 +370,17 @@ class HomeConnectCoordinator(
) )
else: else:
programs.extend(all_programs.programs) programs.extend(all_programs.programs)
current_program_key = None
program_options = None
for program, event_key in ( for program, event_key in (
(
all_programs.active,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
( (
all_programs.selected, all_programs.selected,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM, EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
), ),
(
all_programs.active,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
),
): ):
if program and program.key: if program and program.key:
events[event_key] = Event( events[event_key] = Event(
@@ -370,10 +391,41 @@ class HomeConnectCoordinator(
"", "",
program.key, 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( appliance_data = HomeConnectApplianceData(
commands=commands,
events=events, events=events,
info=appliance, info=appliance,
options=options,
programs=programs, programs=programs,
settings=settings, settings=settings,
status=status, status=status,
@@ -383,3 +435,48 @@ class HomeConnectCoordinator(
appliance_data = appliance_data_to_update appliance_data = appliance_data_to_update
return appliance_data 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()

View File

@@ -1,17 +1,22 @@
"""Home Connect entity base class.""" """Home Connect entity base class."""
from abc import abstractmethod from abc import abstractmethod
import contextlib
import logging 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.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
return ( return (
self.appliance.info.connected and self._attr_available and super().available 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)

View File

@@ -208,6 +208,39 @@
}, },
"door-assistant_freezer": { "door-assistant_freezer": {
"default": "mdi:door" "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"
} }
} }
} }

View File

@@ -3,7 +3,7 @@
import logging import logging
from typing import cast from typing import cast
from aiohomeconnect.model import GetSetting, SettingKey from aiohomeconnect.model import GetSetting, OptionKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.number import ( from homeassistant.components.number import (
@@ -11,6 +11,7 @@ from homeassistant.components.number import (
NumberEntity, NumberEntity,
NumberEntityDescription, NumberEntityDescription,
) )
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,11 +25,17 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_VALUE, SVE_TRANSLATION_PLACEHOLDER_VALUE,
) )
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
NUMBERS = ( NUMBERS = (
NumberEntityDescription( NumberEntityDescription(
@@ -76,6 +83,11 @@ NUMBERS = (
device_class=NumberDeviceClass.TEMPERATURE, device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_3_setpoint_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( NumberEntityDescription(
key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL, key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL,
device_class=NumberDeviceClass.VOLUME, 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( def _get_entities_for_appliance(
entry: HomeConnectConfigEntry, 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
@@ -111,6 +161,7 @@ async def async_setup_entry(
entry, entry,
_get_entities_for_appliance, _get_entities_for_appliance,
async_add_entities, async_add_entities,
_get_option_entities_for_appliance,
) )
@@ -184,3 +235,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
or not hasattr(self, "_attr_native_step") or not hasattr(self, "_attr_native_step")
): ):
await self.async_fetch_constraints() 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

View File

@@ -1,11 +1,12 @@
"""Provides a select platform for Home Connect.""" """Provides a select platform for Home Connect."""
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, cast from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient 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.error import HomeConnectError
from aiohomeconnect.model.program import Execution 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 .common import setup_home_connect_entry
from .const import ( from .const import (
APPLIANCES_WITH_PROGRAMS, APPLIANCES_WITH_PROGRAMS,
AVAILABLE_MAPS_ENUM,
BEAN_AMOUNT_OPTIONS,
BEAN_CONTAINER_OPTIONS,
CLEANING_MODE_OPTIONS,
COFFEE_MILK_RATIO_OPTIONS,
COFFEE_TEMPERATURE_OPTIONS,
DOMAIN, DOMAIN,
DRYING_TARGET_OPTIONS,
FLOW_RATE_OPTIONS,
HOT_WATER_TEMPERATURE_OPTIONS,
INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP, 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_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP, TRANSLATION_KEYS_PROGRAMS_MAP,
VARIO_PERFECT_OPTIONS,
VENTING_LEVEL_OPTIONS,
WARMING_LEVEL_OPTIONS,
) )
from .coordinator import ( from .coordinator import (
HomeConnectApplianceData, HomeConnectApplianceData,
HomeConnectConfigEntry, HomeConnectConfigEntry,
HomeConnectCoordinator, HomeConnectCoordinator,
) )
from .entity import HomeConnectEntity from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error 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) @dataclass(frozen=True, kw_only=True)
@@ -44,6 +87,14 @@ class HomeConnectProgramSelectEntityDescription(
error_translation_key: str 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 = ( PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
HomeConnectProgramSelectEntityDescription( HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM, 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( def _get_entities_for_appliance(
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData, appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]: ) -> list[HomeConnectEntity]:
"""Get a list of entities.""" """Get a list of entities."""
return ( return [
*(
[ [
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc) HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
] ]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS if appliance.info.type in APPLIANCES_WITH_PROGRAMS
else [] 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( async def async_setup_entry(
@@ -91,6 +347,7 @@ async def async_setup_entry(
entry, entry,
_get_entities_for_appliance, _get_entities_for_appliance,
async_add_entities, async_add_entities,
_get_option_entities_for_appliance,
) )
@@ -148,3 +405,122 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value, SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
}, },
) from err ) 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)

View File

@@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription, SensorEntityDescription,
SensorStateClass, SensorStateClass,
) )
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfVolume
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify from homeassistant.util import dt as dt_util, slugify
@@ -56,12 +56,6 @@ BSH_PROGRAM_SENSORS = (
"WasherDryer", "WasherDryer",
), ),
), ),
HomeConnectSensorEntityDescription(
key=EventKey.BSH_COMMON_OPTION_DURATION,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
appliance_types=("Oven",),
),
HomeConnectSensorEntityDescription( HomeConnectSensorEntityDescription(
key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS, key=EventKey.BSH_COMMON_OPTION_PROGRAM_PROGRESS,
native_unit_of_measurement=PERCENTAGE, native_unit_of_measurement=PERCENTAGE,

View File

@@ -33,6 +33,9 @@
"appliance_not_found": { "appliance_not_found": {
"message": "Appliance for device ID {device_id} 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": { "turn_on_light": {
"message": "Error turning on {entity_id}: {error}" "message": "Error turning on {entity_id}: {error}"
}, },
@@ -98,6 +101,9 @@
}, },
"required_program_or_one_option_at_least": { "required_program_or_one_option_at_least": {
"message": "A program or at least one of the possible options for a program should be specified" "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": { "issues": {
@@ -105,6 +111,10 @@
"title": "Deprecated binary door sensor detected in some automations or scripts", "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." "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": { "deprecated_program_switch": {
"title": "Deprecated program switch detected in some automations or scripts", "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." "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" "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": { "light": {
"cooking_lighting": { "cooking_lighting": {
"name": "Functional light" "name": "Functional light"
@@ -854,11 +881,29 @@
"wine_compartment_3_setpoint_temperature": { "wine_compartment_3_setpoint_temperature": {
"name": "Wine compartment 3 temperature" "name": "Wine compartment 3 temperature"
}, },
"color_temperature_percent": {
"name": "Functional light color temperature percent"
},
"washer_i_dos_1_base_level": { "washer_i_dos_1_base_level": {
"name": "i-Dos 1 base level" "name": "i-Dos 1 base level"
}, },
"washer_i_dos_2_base_level": { "washer_i_dos_2_base_level": {
"name": "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": { "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_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%]" "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": { "sensor": {
@@ -1365,6 +1630,45 @@
}, },
"door_assistant_freezer": { "door_assistant_freezer": {
"name": "Freezer door assistant" "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": { "time": {

View File

@@ -3,7 +3,7 @@
import logging import logging
from typing import Any, cast 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.error import HomeConnectError
from aiohomeconnect.model.program import EnumerateProgram from aiohomeconnect.model.program import EnumerateProgram
@@ -37,7 +37,7 @@ from .coordinator import (
HomeConnectConfigEntry, HomeConnectConfigEntry,
HomeConnectCoordinator, HomeConnectCoordinator,
) )
from .entity import HomeConnectEntity from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
translation_key="power", 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( def _get_entities_for_appliance(
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
@@ -123,10 +178,21 @@ def _get_entities_for_appliance(
for description in SWITCHES for description in SWITCHES
if description.key in appliance.settings if description.key in appliance.settings
) )
return entities 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
entry: HomeConnectConfigEntry, entry: HomeConnectConfigEntry,
@@ -137,6 +203,7 @@ async def async_setup_entry(
entry, entry,
_get_entities_for_appliance, _get_entities_for_appliance,
async_add_entities, async_add_entities,
_get_option_entities_for_appliance,
) )
@@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
self.power_off_state = BSH_POWER_STANDBY self.power_off_state = BSH_POWER_STANDBY
else: else:
self.power_off_state = None 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)

View File

@@ -10,7 +10,7 @@
"loggers": ["pyhap"], "loggers": ["pyhap"],
"requirements": [ "requirements": [
"HAP-python==4.9.2", "HAP-python==4.9.2",
"fnv-hash-fast==1.2.2", "fnv-hash-fast==1.2.3",
"PyQRCode==1.2.1", "PyQRCode==1.2.1",
"base36==0.1.1" "base36==0.1.1"
], ],

View File

@@ -10,6 +10,6 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiohue"], "loggers": ["aiohue"],
"requirements": ["aiohue==4.7.3"], "requirements": ["aiohue==4.7.4"],
"zeroconf": ["_hue._tcp.local."] "zeroconf": ["_hue._tcp.local."]
} }

View File

@@ -44,7 +44,7 @@
"fields": { "fields": {
"cycle": { "cycle": {
"name": "[%key:component::input_select::services::select_next::fields::cycle::name%]", "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."
} }
} }
}, },

View File

@@ -49,6 +49,7 @@ from .helpers import (
InputType, InputType,
async_update_config_entry, async_update_config_entry,
generate_unique_id, generate_unique_id,
purge_device_registry,
register_lcn_address_devices, register_lcn_address_devices,
register_lcn_host_device, 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_host_device(hass, config_entry)
register_lcn_address_devices(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 # forward config_entry to components
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

View File

@@ -3,19 +3,18 @@
from collections.abc import Callable from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry 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.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from .const import CONF_DOMAIN_DATA, DOMAIN from .const import DOMAIN
from .helpers import ( from .helpers import (
AddressType, AddressType,
DeviceConnectionType, DeviceConnectionType,
InputType, InputType,
generate_unique_id, generate_unique_id,
get_device_connection, get_device_connection,
get_device_model,
) )
@@ -36,6 +35,14 @@ class LcnEntity(Entity):
self.address: AddressType = config[CONF_ADDRESS] self.address: AddressType = config[CONF_ADDRESS]
self._unregister_for_inputs: Callable | None = None self._unregister_for_inputs: Callable | None = None
self._name: str = config[CONF_NAME] self._name: str = config[CONF_NAME]
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
generate_unique_id(self.config_entry.entry_id, self.address),
)
},
)
@property @property
def unique_id(self) -> str: def unique_id(self) -> str:
@@ -44,28 +51,6 @@ class LcnEntity(Entity):
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE] 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: async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass.""" """Run when entity about to be added to hass."""
self.device_connection = get_device_connection( self.device_connection = get_device_connection(

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio import asyncio
from copy import deepcopy from copy import deepcopy
from itertools import chain
import re import re
from typing import cast from typing import cast
@@ -22,7 +21,6 @@ from homeassistant.const import (
CONF_NAME, CONF_NAME,
CONF_RESOURCE, CONF_RESOURCE,
CONF_SENSORS, CONF_SENSORS,
CONF_SOURCE,
CONF_SWITCHES, CONF_SWITCHES,
) )
from homeassistant.core import HomeAssistant 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 homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
BINSENSOR_PORTS,
CONF_CLIMATES, CONF_CLIMATES,
CONF_HARDWARE_SERIAL, CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE, CONF_HARDWARE_TYPE,
CONF_OUTPUT,
CONF_SCENES, CONF_SCENES,
CONF_SOFTWARE_SERIAL, CONF_SOFTWARE_SERIAL,
CONNECTION, CONNECTION,
DEVICE_CONNECTIONS, DEVICE_CONNECTIONS,
DOMAIN, DOMAIN,
LED_PORTS,
LOGICOP_PORTS,
OUTPUT_PORTS,
S0_INPUTS,
SETPOINTS,
THRESHOLDS,
VARIABLES,
) )
# typing # typing
@@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
raise ValueError("Unknown domain") 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( def generate_unique_id(
entry_id: str, entry_id: str,
address: AddressType, address: AddressType,
@@ -169,13 +133,6 @@ def purge_device_registry(
) -> None: ) -> None:
"""Remove orphans from device registry which are not in entry data.""" """Remove orphans from device registry which are not in entry data."""
device_registry = dr.async_get(hass) 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. # Find device that references the host.
references_host = set() references_host = set()
@@ -198,7 +155,6 @@ def purge_device_registry(
entry.id entry.id
for entry in dr.async_entries_for_config_entry(device_registry, entry_id) for entry in dr.async_entries_for_config_entry(device_registry, entry_id)
} }
- references_entities
- references_host - references_host
- references_entry_data - references_entry_data
) )

View File

@@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS, DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS,
DeviceType.WASHTOWER: WASHER_NUMBERS, DeviceType.WASHTOWER: WASHER_NUMBERS,
DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS, DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS,
DeviceType.WATER_HEATER: ( DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],),
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.WINE_CELLAR: ( DeviceType.WINE_CELLAR: (
NUMBER_DESC[ThinQProperty.LIGHT_STATUS], NUMBER_DESC[ThinQProperty.LIGHT_STATUS],
NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE], NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],
@@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity):
) is not None: ) is not None:
self._attr_native_unit_of_measurement = unit_of_measurement self._attr_native_unit_of_measurement = unit_of_measurement
# Undate range. # Update range.
if ( if (
self.entity_description.native_min_value is None self.entity_description.native_min_value is None
and (min_value := self.data.min) is not None and (min_value := self.data.min) is not None

View File

@@ -581,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
local_now = datetime.now( local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone) tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
) )
if value in [0, None, time.min]: self._device_state = (
# 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.coordinator.data[self._device_state_id].value self.coordinator.data[self._device_state_id].value
if self._device_state_id in self.coordinator.data if self._device_state_id in self.coordinator.data
else None else None
) )
if ( if value in [0, None, time.min] or (
self.native_value is not None self._device_state == "power_off"
and self._device_state == new_state and self.entity_description.key
in [TimerProperty.REMAIN, TimerProperty.TOTAL]
): ):
# Skip update when same state # Reset to None when power_off
return value = None
elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
self._device_state = new_state if self.entity_description.key in TIME_SENSOR_DESC:
time_delta = timedelta( # 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 hours=value.hour, minutes=value.minute, seconds=value.second
) )
value = ( new_time = (
(local_now - time_delta) (local_now - event_data)
if self.entity_description.key == TimerProperty.RUNNING 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: elif self.entity_description.device_class == SensorDeviceClass.DURATION:
# Set duration # Set duration
value = self._get_duration( value = self._get_duration(

View File

@@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30"
MODELS_ARYLIC_A50: Final[str] = "A50" MODELS_ARYLIC_A50: Final[str] = "A50"
MODELS_ARYLIC_A50S: Final[str] = "A50+" MODELS_ARYLIC_A50S: Final[str] = "A50+"
MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0" 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_V3: Final[str] = "Up2Stream Amp v3"
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4" MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1" MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3" 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_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5" MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
MODELS_WIIM_AMP: Final[str] = "WiiM Amp" 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_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
"UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4), "UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
"UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3), "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), "ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
"UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "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), "RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC), "ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),

View File

@@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate):
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}" self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
self._attr_device_info = self.api.device_info 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 @property
def extra_state_attributes(self) -> dict[str, Any] | None: def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the optional state attributes with device specific additions.""" """Return the optional state attributes with device specific additions."""
@@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate):
"""Return vertical vane position or mode.""" """Return vertical vane position or mode."""
return self._device.vane_vertical 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: async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set vertical vane position or mode.""" """Set vertical vane position or mode."""
await self.async_set_vane_vertical(swing_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 @property
def swing_modes(self) -> list[str] | None: def swing_modes(self) -> list[str] | None:
"""Return a list of available vertical vane positions and modes.""" """Return a list of available vertical vane positions and modes."""
return self._device.vane_vertical_positions 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: async def async_turn_on(self) -> None:
"""Turn the entity on.""" """Turn the entity on."""
await self._device.set({"power": True}) await self._device.set({"power": True})

View File

@@ -9,15 +9,13 @@ import dns.rdata
import dns.rdataclass import dns.rdataclass
import dns.rdatatype import dns.rdatatype
from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers import device_registry as dr, entity_registry as er
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
from .coordinator import MinecraftServerCoordinator from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] 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] 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.""" """Set up Minecraft Server from a config entry."""
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083) # Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
await hass.async_add_executor_job(load_dnspython_rdata_classes) await hass.async_add_executor_job(load_dnspython_rdata_classes)
# Create API instance. # Create coordinator instance and store it.
api = MinecraftServer( coordinator = MinecraftServerCoordinator(hass, entry)
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)
await coordinator.async_config_entry_first_refresh() await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
# Store coordinator instance.
domain_data = hass.data.setdefault(DOMAIN, {})
domain_data[entry.entry_id] = coordinator
# Set up platforms. # Set up platforms.
await hass.config_entries.async_forward_entry_setups(entry, 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 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 Minecraft Server config entry."""
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
# 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
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.""" """Migrate old config entry to a new format."""
# 1 --> 2: Use config entry ID as base for unique IDs. # 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( 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: ) -> None:
"""Migrate the device identifiers to the new format.""" """Migrate the device identifiers to the new format."""
device_registry = dr.async_get(hass) device_registry = dr.async_get(hass)

View File

@@ -5,12 +5,10 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity, BinarySensorEntity,
BinarySensorEntityDescription, BinarySensorEntityDescription,
) )
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .coordinator import MinecraftServerCoordinator
from .entity import MinecraftServerEntity from .entity import MinecraftServerEntity
KEY_STATUS = "status" KEY_STATUS = "status"
@@ -27,11 +25,11 @@ BINARY_SENSOR_DESCRIPTIONS = [
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: MinecraftServerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Minecraft Server binary sensor platform.""" """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. # Add binary sensor entities.
async_add_entities( async_add_entities(
@@ -49,7 +47,7 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit
self, self,
coordinator: MinecraftServerCoordinator, coordinator: MinecraftServerCoordinator,
description: BinarySensorEntityDescription, description: BinarySensorEntityDescription,
config_entry: ConfigEntry, config_entry: MinecraftServerConfigEntry,
) -> None: ) -> None:
"""Initialize binary sensor base entity.""" """Initialize binary sensor base entity."""
super().__init__(coordinator, config_entry) super().__init__(coordinator, config_entry)

View File

@@ -8,10 +8,10 @@ from typing import Any
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult 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 .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
from .const import DEFAULT_NAME, DOMAIN from .const import DOMAIN
DEFAULT_ADDRESS = "localhost:25565" DEFAULT_ADDRESS = "localhost:25565"
@@ -37,7 +37,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
# Prepare config entry data. # Prepare config entry data.
config_data = { config_data = {
CONF_NAME: user_input[CONF_NAME],
CONF_ADDRESS: address, CONF_ADDRESS: address,
} }
@@ -78,9 +77,6 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="user", step_id="user",
data_schema=vol.Schema( data_schema=vol.Schema(
{ {
vol.Required(
CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME)
): str,
vol.Required( vol.Required(
CONF_ADDRESS, CONF_ADDRESS,
default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS), default=user_input.get(CONF_ADDRESS, DEFAULT_ADDRESS),

View File

@@ -1,7 +1,5 @@
"""Constants for the Minecraft Server integration.""" """Constants for the Minecraft Server integration."""
DEFAULT_NAME = "Minecraft Server"
DOMAIN = "minecraft_server" DOMAIN = "minecraft_server"
KEY_LATENCY = "latency" KEY_LATENCY = "latency"

View File

@@ -6,17 +6,22 @@ from datetime import timedelta
import logging import logging
from homeassistant.config_entries import ConfigEntry 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import ( from .api import (
MinecraftServer, MinecraftServer,
MinecraftServerAddressError,
MinecraftServerConnectionError, MinecraftServerConnectionError,
MinecraftServerData, MinecraftServerData,
MinecraftServerNotInitializedError, MinecraftServerNotInitializedError,
MinecraftServerType,
) )
type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator]
SCAN_INTERVAL = timedelta(seconds=60) SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -25,25 +30,40 @@ _LOGGER = logging.getLogger(__name__)
class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
"""Minecraft Server data update coordinator.""" """Minecraft Server data update coordinator."""
config_entry: ConfigEntry config_entry: MinecraftServerConfigEntry
_api: MinecraftServer
def __init__( def __init__(
self, self,
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: MinecraftServerConfigEntry,
api: MinecraftServer,
) -> None: ) -> None:
"""Initialize coordinator instance.""" """Initialize coordinator instance."""
self._api = api
super().__init__( super().__init__(
hass=hass, hass=hass,
name=config_entry.data[CONF_NAME], name=config_entry.title,
config_entry=config_entry, config_entry=config_entry,
logger=_LOGGER, logger=_LOGGER,
update_interval=SCAN_INTERVAL, 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: async def _async_update_data(self) -> MinecraftServerData:
"""Get updated data from the server.""" """Get updated data from the server."""
try: try:

View File

@@ -5,20 +5,19 @@ from dataclasses import asdict
from typing import Any from typing import Any
from homeassistant.components.diagnostics import async_redact_data from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.core import HomeAssistant 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( async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return diagnostics for a config entry.""" """Return diagnostics for a config entry."""
coordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator = config_entry.runtime_data
return { return {
"config_entry": { "config_entry": {

View File

@@ -6,9 +6,7 @@ rules:
appropriate-polling: done appropriate-polling: done
brands: done brands: done
common-modules: done common-modules: done
config-flow: config-flow: done
status: todo
comment: Check removal and replacement of name in config flow with the title (server address).
config-flow-test-coverage: config-flow-test-coverage:
status: todo status: todo
comment: | comment: |
@@ -29,7 +27,7 @@ rules:
status: done status: done
comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information. comment: Using confid entry ID as the dependency mcstatus doesn't provide a unique information.
has-entity-name: done has-entity-name: done
runtime-data: todo runtime-data: done
test-before-configure: done test-before-configure: done
test-before-setup: test-before-setup:
status: done status: done

View File

@@ -7,15 +7,14 @@ from dataclasses import dataclass
from typing import Any from typing import Any
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType from homeassistant.helpers.typing import StateType
from .api import MinecraftServerData, MinecraftServerType from .api import MinecraftServerData, MinecraftServerType
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .const import KEY_LATENCY, KEY_MOTD
from .coordinator import MinecraftServerCoordinator from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
from .entity import MinecraftServerEntity from .entity import MinecraftServerEntity
ATTR_PLAYERS_LIST = "players_list" ATTR_PLAYERS_LIST = "players_list"
@@ -158,11 +157,11 @@ SENSOR_DESCRIPTIONS = [
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: MinecraftServerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback, async_add_entities: AddConfigEntryEntitiesCallback,
) -> None: ) -> None:
"""Set up the Minecraft Server sensor platform.""" """Set up the Minecraft Server sensor platform."""
coordinator = hass.data[DOMAIN][config_entry.entry_id] coordinator = config_entry.runtime_data
# Add sensor entities. # Add sensor entities.
async_add_entities( async_add_entities(
@@ -184,7 +183,7 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity):
self, self,
coordinator: MinecraftServerCoordinator, coordinator: MinecraftServerCoordinator,
description: MinecraftServerSensorEntityDescription, description: MinecraftServerSensorEntityDescription,
config_entry: ConfigEntry, config_entry: MinecraftServerConfigEntry,
) -> None: ) -> None:
"""Initialize sensor base entity.""" """Initialize sensor base entity."""
super().__init__(coordinator, config_entry) super().__init__(coordinator, config_entry)

View File

@@ -2,12 +2,14 @@
"config": { "config": {
"step": { "step": {
"user": { "user": {
"title": "Link your Minecraft Server",
"description": "Set up your Minecraft Server instance to allow monitoring.",
"data": { "data": {
"name": "[%key:common::config_flow::data::name%]",
"address": "Server address" "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": { "abort": {

View File

@@ -384,6 +384,11 @@ class ModbusHub:
{ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1} {ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1}
) )
entry = self._pb_request[use_call] 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 kwargs[entry.value_attr_name] = value
try: try:
result: ModbusPDU = await entry.func(address, **kwargs) result: ModbusPDU = await entry.func(address, **kwargs)

View File

@@ -1,7 +1,7 @@
{ {
"domain": "motionmount", "domain": "motionmount",
"name": "Vogel's MotionMount", "name": "Vogel's MotionMount",
"codeowners": ["@RJPoelstra"], "codeowners": ["@laiho-vogels"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/motionmount", "documentation": "https://www.home-assistant.io/integrations/motionmount",
"integration_type": "device", "integration_type": "device",

View File

@@ -58,12 +58,12 @@
}, },
"services": { "services": {
"lock_n_go": { "lock_n_go": {
"name": "Lock 'n' go", "name": "Lock 'n' Go",
"description": "Nuki Lock 'n' Go.", "description": "Unlocks the door, waits a few seconds then re-locks. The wait period can be customized through the app.",
"fields": { "fields": {
"unlatch": { "unlatch": {
"name": "Unlatch", "name": "Unlatch",
"description": "Whether to unlatch the lock." "description": "Whether to also unlatch the door when unlocking it."
} }
} }
}, },

View File

@@ -39,8 +39,8 @@ async def async_get_config_entry_diagnostics(
hass_device = device_registry.async_get_device( hass_device = device_registry.async_get_device(
identifiers={(DOMAIN, hass_data.unique_id)} identifiers={(DOMAIN, hass_data.unique_id)}
) )
if not hass_device: # Device is always created
return data assert hass_device is not None
data["device"] = { data["device"] = {
**attr.asdict(hass_device), **attr.asdict(hass_device),

View File

@@ -1,6 +1,12 @@
{ {
"entity": { "entity": {
"sensor": { "sensor": {
"ambient_humidity_status": {
"default": "mdi:information-outline"
},
"ambient_temperature_status": {
"default": "mdi:information-outline"
},
"battery_alarm_threshold": { "battery_alarm_threshold": {
"default": "mdi:information-outline" "default": "mdi:information-outline"
}, },

View File

@@ -1,7 +1,7 @@
{ {
"domain": "nut", "domain": "nut",
"name": "Network UPS Tools (NUT)", "name": "Network UPS Tools (NUT)",
"codeowners": ["@bdraco", "@ollo69", "@pestevez"], "codeowners": ["@bdraco", "@ollo69", "@pestevez", "@tdfountain"],
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/nut", "documentation": "https://www.home-assistant.io/integrations/nut",
"integration_type": "device", "integration_type": "device",

View File

@@ -46,8 +46,17 @@ NUT_DEV_INFO_TO_DEV_INFO: dict[str, str] = {
"serial": ATTR_SERIAL_NUMBER, "serial": ATTR_SERIAL_NUMBER,
} }
AMBIENT_THRESHOLD_STATUS_OPTIONS = [
"good",
"warning-low",
"critical-low",
"warning-high",
"critical-high",
]
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
"ups.status.display": SensorEntityDescription( "ups.status.display": SensorEntityDescription(
key="ups.status.display", key="ups.status.display",
@@ -930,6 +939,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, 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( "ambient.temperature": SensorEntityDescription(
key="ambient.temperature", key="ambient.temperature",
translation_key="ambient_temperature", translation_key="ambient_temperature",
@@ -938,6 +954,13 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = {
state_class=SensorStateClass.MEASUREMENT, state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC, 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( "watts": SensorEntityDescription(
key="watts", key="watts",
translation_key="watts", translation_key="watts",

View File

@@ -80,7 +80,9 @@
"entity": { "entity": {
"sensor": { "sensor": {
"ambient_humidity": { "name": "Ambient humidity" }, "ambient_humidity": { "name": "Ambient humidity" },
"ambient_humidity_status": { "name": "Ambient humidity status" },
"ambient_temperature": { "name": "Ambient temperature" }, "ambient_temperature": { "name": "Ambient temperature" },
"ambient_temperature_status": { "name": "Ambient temperature status" },
"battery_alarm_threshold": { "name": "Battery alarm threshold" }, "battery_alarm_threshold": { "name": "Battery alarm threshold" },
"battery_capacity": { "name": "Battery capacity" }, "battery_capacity": { "name": "Battery capacity" },
"battery_charge": { "name": "Battery charge" }, "battery_charge": { "name": "Battery charge" },

View File

@@ -1,7 +1,7 @@
{ {
"domain": "onboarding", "domain": "onboarding",
"name": "Home Assistant Onboarding", "name": "Home Assistant Onboarding",
"after_dependencies": ["backup", "hassio"], "after_dependencies": ["backup"],
"codeowners": ["@home-assistant/core"], "codeowners": ["@home-assistant/core"],
"dependencies": ["auth", "http", "person"], "dependencies": ["auth", "http", "person"],
"documentation": "https://www.home-assistant.io/integrations/onboarding", "documentation": "https://www.home-assistant.io/integrations/onboarding",

View File

@@ -29,7 +29,6 @@ from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import area_registry as ar 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.system_info import async_get_system_info
from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.translation import async_get_translations
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@@ -224,16 +223,6 @@ class CoreConfigOnboardingView(_BaseOnboardingView):
"shopping_list", "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: for domain in onboard_integrations:
# Create tasks so onboarding isn't affected # Create tasks so onboarding isn't affected
# by errors in these integrations. # by errors in these integrations.

View File

@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from html import unescape from html import unescape
from json import dumps, loads from json import dumps, loads
import logging import logging
@@ -10,13 +11,13 @@ from typing import cast
from onedrive_personal_sdk import OneDriveClient from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.exceptions import ( from onedrive_personal_sdk.exceptions import (
AuthenticationError, AuthenticationError,
HttpRequestException, NotFoundError,
OneDriveException, 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.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.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import ( 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 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 ( from .coordinator import (
OneDriveConfigEntry, OneDriveConfigEntry,
OneDriveRuntimeData, OneDriveRuntimeData,
@@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
client = OneDriveClient(get_access_token, async_get_clientsession(hass)) client = OneDriveClient(get_access_token, async_get_clientsession(hass))
# get approot, will be created automatically if it does not exist # get approot, will be created automatically if it does not exist
try: approot = await _handle_item_operation(client.get_approot, "approot")
approot = await client.get_approot() folder_name = entry.data[CONF_FOLDER_NAME]
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
instance_id = await async_get_instance_id(hass)
backup_folder_name = f"backups_{instance_id[:8]}"
try: try:
backup_folder = await client.create_folder( backup_folder = await _handle_item_operation(
parent_id=approot.id, name=backup_folder_name 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) coordinator = OneDriveUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh() 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", translation_key="failed_to_migrate_files",
) from err ) from err
_async_notify_backup_listeners_soon(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None: 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)) 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 return True
async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Unload a OneDrive config entry.""" """Unload a OneDrive config entry."""
_async_notify_backup_listeners_soon(hass)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) 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: async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None:
"""Migrate backup files to metadata version 2.""" """Migrate backup files to metadata version 2."""
files = await client.list_drive_items(backup_folder_id) 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=""), data=ItemUpdate(description=""),
) )
_LOGGER.debug("Migrated backup file %s", file.name) _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

View File

@@ -74,7 +74,7 @@ def async_register_backup_agents_listener(
def handle_backup_errors[_R, **P]( def handle_backup_errors[_R, **P](
func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]], func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]],
) -> 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) @wraps(func)
async def wrapper( async def wrapper(

View File

@@ -8,22 +8,47 @@ from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException from onedrive_personal_sdk.exceptions import OneDriveException
from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
import voluptuous as vol 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.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler 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 from .coordinator import OneDriveConfigEntry
FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str})
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN): class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle OneDrive OAuth2 authentication.""" """Config flow to handle OneDrive OAuth2 authentication."""
DOMAIN = DOMAIN 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 @property
def logger(self) -> logging.Logger: 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.""" """Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(OAUTH_SCOPES)} 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( async def async_oauth_create_entry(
self, self,
data: dict[str, Any], data: dict[str, Any],
@@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def get_access_token() -> str: async def get_access_token() -> str:
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
graph_client = OneDriveClient( self.client = OneDriveClient(
get_access_token, async_get_clientsession(self.hass) get_access_token, async_get_clientsession(self.hass)
) )
try: try:
approot = await graph_client.get_approot() self.approot = await self.client.get_approot()
except OneDriveException: except OneDriveException:
self.logger.exception("Failed to connect to OneDrive") self.logger.exception("Failed to connect to OneDrive")
return self.async_abort(reason="connection_error") return self.async_abort(reason="connection_error")
@@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self.logger.exception("Unknown error") self.logger.exception("Unknown error")
return self.async_abort(reason="unknown") 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: if self.source != SOURCE_USER:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch( self._abort_if_unique_id_mismatch(
reason="wrong_drive", reason="wrong_drive",
) )
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort( return self.async_update_reload_and_abort(
entry=reauth_entry, entry=reauth_entry,
data=data, data=data,
) )
if self.source != SOURCE_RECONFIGURE:
self._abort_if_unique_id_configured() 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 = ( title = (
f"{approot.created_by.user.display_name}'s OneDrive" f"{self.approot.created_by.user.display_name}'s OneDrive"
if approot.created_by.user and approot.created_by.user.display_name if self.approot.created_by.user
and self.approot.created_by.user.display_name
else "OneDrive" 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( async def async_step_reauth(
self, entry_data: Mapping[str, Any] 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 self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user() 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 @staticmethod
@callback @callback
def async_get_options_flow( def async_get_options_flow(

View File

@@ -6,6 +6,8 @@ from typing import Final
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
DOMAIN: Final = "onedrive" DOMAIN: Final = "onedrive"
CONF_FOLDER_NAME: Final = "folder_name"
CONF_FOLDER_ID: Final = "folder_id"
CONF_DELETE_PERMANENTLY: Final = "delete_permanently" CONF_DELETE_PERMANENTLY: Final = "delete_permanently"

View File

@@ -8,6 +8,6 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"], "loggers": ["onedrive_personal_sdk"],
"quality_scale": "bronze", "quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.0.11"] "requirements": ["onedrive-personal-sdk==0.0.11"]
} }

View File

@@ -73,10 +73,7 @@ rules:
entity-translations: done entity-translations: done
exception-translations: done exception-translations: done
icon-translations: done icon-translations: done
reconfiguration-flow: reconfiguration-flow: done
status: exempt
comment: |
Nothing to reconfigure.
repair-issues: done repair-issues: done
stale-devices: stale-devices:
status: exempt status: exempt

View File

@@ -7,6 +7,26 @@
"reauth_confirm": { "reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]", "title": "[%key:common::config_flow::title::reauth%]",
"description": "The OneDrive integration needs to re-authenticate your account" "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": { "abort": {
@@ -23,10 +43,16 @@
"connection_error": "Failed to connect to OneDrive.", "connection_error": "Failed to connect to OneDrive.",
"wrong_drive": "New account does not contain previously configured OneDrive.", "wrong_drive": "New account does not contain previously configured OneDrive.",
"unknown": "[%key:common::config_flow::error::unknown%]", "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": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "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": { "options": {

View File

@@ -287,6 +287,9 @@ class OpenAIConversationEntity(
try: try:
result = await client.chat.completions.create(**model_args) 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: except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err) LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err raise HomeAssistantError("Error talking to OpenAI") from err

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/prosegur", "documentation": "https://www.home-assistant.io/integrations/prosegur",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["pyprosegur"], "loggers": ["pyprosegur"],
"requirements": ["pyprosegur==0.0.13"] "requirements": ["pyprosegur==0.0.14"]
} }

View File

@@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
) )
return None return None
distance_to_zone = distance( distance_to_centre = distance(
zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LATITUDE],
zone.attributes[ATTR_LONGITUDE], zone.attributes[ATTR_LONGITUDE],
latitude, 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 # it is ensured, that distance can't be None, since zones must have lat/lon coordinates
assert distance_to_zone is not None assert distance_to_centre is not None
return round(distance_to_zone)
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( def _calc_direction_of_travel(
self, self,

View File

@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
from aiohttp import CookieJar from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI from pyloadapi import PyLoadAPI
from homeassistant.const import ( from homeassistant.const import (
CONF_HOST, CONF_HOST,

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from typing import Any 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.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant

View File

@@ -7,8 +7,7 @@ import logging
from typing import Any from typing import Any
from aiohttp import CookieJar from aiohttp import CookieJar
from pyloadapi.api import PyLoadAPI from pyloadapi import CannotConnect, InvalidAuth, ParserError, PyLoadAPI
from pyloadapi.exceptions import CannotConnect, InvalidAuth, ParserError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import ConfigFlow, ConfigFlowResult

View File

@@ -78,10 +78,14 @@ class PyLoadCoordinator(DataUpdateCoordinator[PyLoadData]):
return self.data return self.data
except CannotConnect as e: except CannotConnect as e:
raise UpdateFailed( raise UpdateFailed(
"Unable to connect and retrieve data from pyLoad API" translation_domain=DOMAIN,
translation_key="setup_request_exception",
) from e ) from e
except ParserError as 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: async def _async_setup(self) -> None:
"""Set up the coordinator.""" """Set up the coordinator."""

View File

@@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyloadapi"], "loggers": ["pyloadapi"],
"requirements": ["PyLoadAPI==1.4.1"] "requirements": ["PyLoadAPI==1.4.2"]
} }

View File

@@ -12,7 +12,11 @@
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of the device running your pyLoad instance.", "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": { "reconfigure": {
@@ -25,8 +29,12 @@
"port": "[%key:common::config_flow::data::port%]" "port": "[%key:common::config_flow::data::port%]"
}, },
"data_description": { "data_description": {
"host": "The hostname or IP address of the device running your pyLoad instance.", "host": "[%key:component::pyload::config::step::user::data_description::host%]",
"port": "pyLoad uses port 8000 by default." "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": { "reauth_confirm": {
@@ -34,6 +42,10 @@
"data": { "data": {
"username": "[%key:common::config_flow::data::username%]", "username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]" "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": { "exceptions": {
"setup_request_exception": { "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": { "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": { "setup_authentication_exception": {
"message": "Authentication failed for {username}, verify your login credentials" "message": "Authentication failed for {username}, verify your login credentials"

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
from typing import Any from typing import Any
from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI
from homeassistant.components.switch import ( from homeassistant.components.switch import (
SwitchDeviceClass, SwitchDeviceClass,

View File

@@ -8,6 +8,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import recorder as recorder_helper
from .util import get_instance from .util import get_instance
@@ -23,12 +24,15 @@ def async_setup(hass: HomeAssistant) -> None:
vol.Required("type"): "recorder/info", vol.Required("type"): "recorder/info",
} }
) )
@callback @websocket_api.async_response
def ws_info( async def ws_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None: ) -> None:
"""Return status of the recorder.""" """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 backlog = instance.backlog
migration_in_progress = instance.migration_in_progress migration_in_progress = instance.migration_in_progress
migration_is_live = instance.migration_is_live 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. # for the thread state lock which will block the event loop.
is_running = instance.is_running is_running = instance.is_running
max_backlog = instance.max_backlog 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 = { recorder_info = {
"backlog": backlog, "backlog": backlog,

View File

@@ -50,6 +50,11 @@ STATES_META_SCHEMA_VERSION = 38
LAST_REPORTED_SCHEMA_VERSION = 43 LAST_REPORTED_SCHEMA_VERSION = 43
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28 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_COMPILE_STATISTICS = "compile_statistics"
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids" INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids"

View File

@@ -8,7 +8,7 @@
"quality_scale": "internal", "quality_scale": "internal",
"requirements": [ "requirements": [
"SQLAlchemy==2.0.38", "SQLAlchemy==2.0.38",
"fnv-hash-fast==1.2.2", "fnv-hash-fast==1.2.3",
"psutil-home-assistant==0.0.1" "psutil-home-assistant==0.0.1"
] ]
} }

Some files were not shown because too many files have changed in this diff Show More