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 -
- name: Upload translations
uses: actions/upload-artifact@v4.6.0
uses: actions/upload-artifact@v4.6.1
with:
name: translations
path: translations.tar.gz

View File

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

View File

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

View File

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

View File

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

14
CODEOWNERS generated
View File

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

View File

@@ -328,10 +328,10 @@ async def async_setup_hass(
block_async_io.enable()
config_dict = None
basic_setup_success = False
if not (recovery_mode := runtime_config.recovery_mode):
config_dict = None
basic_setup_success = False
await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass)
try:
@@ -349,39 +349,43 @@ async def async_setup_hass(
await async_from_config_dict(config_dict, hass) is not None
)
if config_dict is None:
recovery_mode = True
await stop_hass(hass)
hass = await create_hass()
if config_dict is None:
recovery_mode = True
await stop_hass(hass)
hass = await create_hass()
elif not basic_setup_success:
_LOGGER.warning("Unable to set up core integrations. Activating recovery mode")
recovery_mode = True
await stop_hass(hass)
hass = await create_hass()
elif not basic_setup_success:
_LOGGER.warning(
"Unable to set up core integrations. Activating recovery mode"
)
recovery_mode = True
await stop_hass(hass)
hass = await create_hass()
elif any(domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS):
_LOGGER.warning(
"Detected that %s did not load. Activating recovery mode",
",".join(CRITICAL_INTEGRATIONS),
)
elif any(
domain not in hass.config.components for domain in CRITICAL_INTEGRATIONS
):
_LOGGER.warning(
"Detected that %s did not load. Activating recovery mode",
",".join(CRITICAL_INTEGRATIONS),
)
old_config = hass.config
old_logging = hass.data.get(DATA_LOGGING)
old_config = hass.config
old_logging = hass.data.get(DATA_LOGGING)
recovery_mode = True
await stop_hass(hass)
hass = await create_hass()
recovery_mode = True
await stop_hass(hass)
hass = await create_hass()
if old_logging:
hass.data[DATA_LOGGING] = old_logging
hass.config.debug = old_config.debug
hass.config.skip_pip = old_config.skip_pip
hass.config.skip_pip_packages = old_config.skip_pip_packages
hass.config.internal_url = old_config.internal_url
hass.config.external_url = old_config.external_url
# Setup loader cache after the config dir has been set
loader.async_setup(hass)
if old_logging:
hass.data[DATA_LOGGING] = old_logging
hass.config.debug = old_config.debug
hass.config.skip_pip = old_config.skip_pip
hass.config.skip_pip_packages = old_config.skip_pip_packages
hass.config.internal_url = old_config.internal_url
hass.config.external_url = old_config.external_url
# Setup loader cache after the config dir has been set
loader.async_setup(hass)
if recovery_mode:
_LOGGER.info("Starting in recovery mode")

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,7 +7,7 @@ from collections.abc import Callable
from google_drive_api.exceptions import GoogleDriveApiError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import instance_id
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -49,7 +49,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: GoogleDriveConfigEntry)
except GoogleDriveApiError as err:
raise ConfigEntryNotReady from err
_async_notify_backup_listeners_soon(hass)
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
@@ -58,15 +62,4 @@ async def async_unload_entry(
hass: HomeAssistant, entry: GoogleDriveConfigEntry
) -> bool:
"""Unload a config entry."""
_async_notify_backup_listeners_soon(hass)
return True
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
@callback
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
hass.loop.call_soon(_async_notify_backup_listeners, hass)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -56,10 +56,21 @@
},
"image_filename": {
"name": "Image filename",
"description": "Images",
"description": "Deprecated. Use filenames instead.",
"example": "/config/www/image.jpg"
},
"filenames": {
"name": "Attachment filenames",
"description": "Attachments to add to the prompt (images, PDFs, etc)",
"example": "/config/www/image.jpg"
}
}
}
},
"issues": {
"deprecated_image_filename_parameter": {
"title": "Deprecated 'image_filename' parameter",
"description": "The 'image_filename' parameter in Google Generative AI actions is deprecated. Please edit scripts and automations to use 'filenames' intead."
}
}
}

View File

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

View File

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

View File

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

View File

@@ -187,6 +187,7 @@ SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str})
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,
@@ -212,7 +213,11 @@ async def _get_client_and_ha_id(
break
if entry is None:
raise ServiceValidationError(
"Home Connect config entry not found for that device id"
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={
"device_id": device_id,
},
)
ha_id = next(
@@ -404,6 +409,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
"""Execute calls to services executing a command."""
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
async_create_issue(
hass,
DOMAIN,
"deprecated_command_actions",
breaks_in_ha_version="2025.9.0",
is_fixable=True,
is_persistent=True,
severity=IssueSeverity.WARNING,
translation_key="deprecated_command_actions",
)
try:
await client.put_command(ha_id, command_key=command_key, value=True)
except HomeConnectError as err:
@@ -609,6 +625,7 @@ async def async_unload_entry(
) -> bool:
"""Unload a config entry."""
async_delete_issue(hass, DOMAIN, "deprecated_set_program_and_option_actions")
async_delete_issue(hass, DOMAIN, "deprecated_command_actions")
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

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

View File

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

View File

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

View File

@@ -1,17 +1,22 @@
"""Home Connect entity base class."""
from abc import abstractmethod
import contextlib
import logging
from typing import cast
from aiohomeconnect.model import EventKey
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import HomeConnectApplianceData, HomeConnectCoordinator
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@@ -60,3 +65,59 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
return (
self.appliance.info.connected and self._attr_available and super().available
)
class HomeConnectOptionEntity(HomeConnectEntity):
"""Class for entities that represents program options."""
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.bsh_key in self.appliance.options
@property
def option_value(self) -> str | int | float | bool | None:
"""Return the state of the entity."""
if event := self.appliance.events.get(EventKey(self.bsh_key)):
return event.value
return None
async def async_set_option(self, value: str | float | bool) -> None:
"""Set an option for the entity."""
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id,
option_key=self.bsh_key,
value=value,
)
_LOGGER.debug(
"Updated %s for the active program, new state: %s",
self.entity_id,
self.state,
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id,
option_key=self.bsh_key,
value=value,
)
_LOGGER.debug(
"Updated %s for the selected program, new state: %s",
self.entity_id,
self.state,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
@property
def bsh_key(self) -> OptionKey:
"""Return the BSH key."""
return cast(OptionKey, self.entity_description.key)

View File

@@ -208,6 +208,39 @@
},
"door-assistant_freezer": {
"default": "mdi:door"
},
"silence_on_demand": {
"default": "mdi:volume-mute",
"state": {
"on": "mdi:volume-mute",
"off": "mdi:volume-high"
}
},
"half_load": {
"default": "mdi:fraction-one-half"
},
"hygiene_plus": {
"default": "mdi:silverware-clean"
},
"eco_dry": {
"default": "mdi:sprout"
},
"fast_pre_heat": {
"default": "mdi:fire"
},
"i_dos_1_active": {
"default": "mdi:numeric-1-circle"
},
"i_dos_2_active": {
"default": "mdi:numeric-2-circle"
}
},
"time": {
"start_in_relative": {
"default": "mdi:progress-clock"
},
"finish_in_relative": {
"default": "mdi:progress-clock"
}
}
}

View File

@@ -3,7 +3,7 @@
import logging
from typing import cast
from aiohomeconnect.model import GetSetting, SettingKey
from aiohomeconnect.model import GetSetting, OptionKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from homeassistant.components.number import (
@@ -11,6 +11,7 @@ from homeassistant.components.number import (
NumberEntity,
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -24,11 +25,17 @@ from .const import (
SVE_TRANSLATION_PLACEHOLDER_VALUE,
)
from .coordinator import HomeConnectApplianceData, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
UNIT_MAP = {
"seconds": UnitOfTime.SECONDS,
"ml": UnitOfVolume.MILLILITERS,
"°C": UnitOfTemperature.CELSIUS,
"°F": UnitOfTemperature.FAHRENHEIT,
}
NUMBERS = (
NumberEntityDescription(
@@ -76,6 +83,11 @@ NUMBERS = (
device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_3_setpoint_temperature",
),
NumberEntityDescription(
key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE_PERCENT,
translation_key="color_temperature_percent",
native_unit_of_measurement="%",
),
NumberEntityDescription(
key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL,
device_class=NumberDeviceClass.VOLUME,
@@ -88,6 +100,32 @@ NUMBERS = (
),
)
NUMBER_OPTIONS = (
NumberEntityDescription(
key=OptionKey.BSH_COMMON_DURATION,
translation_key="duration",
),
NumberEntityDescription(
key=OptionKey.BSH_COMMON_FINISH_IN_RELATIVE,
translation_key="finish_in_relative",
),
NumberEntityDescription(
key=OptionKey.BSH_COMMON_START_IN_RELATIVE,
translation_key="start_in_relative",
),
NumberEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FILL_QUANTITY,
translation_key="fill_quantity",
device_class=NumberDeviceClass.VOLUME,
native_step=1,
),
NumberEntityDescription(
key=OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE,
translation_key="setpoint_temperature",
device_class=NumberDeviceClass.TEMPERATURE,
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
@@ -101,6 +139,18 @@ def _get_entities_for_appliance(
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(entry.runtime_data, appliance, description)
for description in NUMBER_OPTIONS
if description.key in appliance.options
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
@@ -111,6 +161,7 @@ async def async_setup_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
_get_option_entities_for_appliance,
)
@@ -184,3 +235,44 @@ class HomeConnectNumberEntity(HomeConnectEntity, NumberEntity):
or not hasattr(self, "_attr_native_step")
):
await self.async_fetch_constraints()
class HomeConnectOptionNumberEntity(HomeConnectOptionEntity, NumberEntity):
"""Number option class for Home Connect."""
async def async_set_native_value(self, value: float) -> None:
"""Set the native value of the entity."""
await self.async_set_option(value)
def update_native_value(self) -> None:
"""Set the value of the entity."""
self._attr_native_value = cast(float | None, self.option_value)
option_definition = self.appliance.options.get(self.bsh_key)
if option_definition:
if option_definition.unit:
candidate_unit = UNIT_MAP.get(
option_definition.unit, option_definition.unit
)
if (
not hasattr(self, "_attr_native_unit_of_measurement")
or candidate_unit != self._attr_native_unit_of_measurement
):
self._attr_native_unit_of_measurement = candidate_unit
self.__dict__.pop("unit_of_measurement", None)
option_constraints = option_definition.constraints
if option_constraints:
if (
not hasattr(self, "_attr_native_min_value")
or self._attr_native_min_value != option_constraints.min
) and option_constraints.min:
self._attr_native_min_value = option_constraints.min
if (
not hasattr(self, "_attr_native_max_value")
or self._attr_native_max_value != option_constraints.max
) and option_constraints.max:
self._attr_native_max_value = option_constraints.max
if (
not hasattr(self, "_attr_native_step")
or self._attr_native_step != option_constraints.step_size
) and option_constraints.step_size:
self._attr_native_step = option_constraints.step_size

View File

@@ -1,11 +1,12 @@
"""Provides a select platform for Home Connect."""
from collections.abc import Callable, Coroutine
import contextlib
from dataclasses import dataclass
from typing import Any, cast
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import EventKey, ProgramKey
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import Execution
@@ -17,18 +18,60 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import (
APPLIANCES_WITH_PROGRAMS,
AVAILABLE_MAPS_ENUM,
BEAN_AMOUNT_OPTIONS,
BEAN_CONTAINER_OPTIONS,
CLEANING_MODE_OPTIONS,
COFFEE_MILK_RATIO_OPTIONS,
COFFEE_TEMPERATURE_OPTIONS,
DOMAIN,
DRYING_TARGET_OPTIONS,
FLOW_RATE_OPTIONS,
HOT_WATER_TEMPERATURE_OPTIONS,
INTENSIVE_LEVEL_OPTIONS,
PROGRAMS_TRANSLATION_KEYS_MAP,
SPIN_SPEED_OPTIONS,
SVE_TRANSLATION_KEY_SET_SETTING,
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID,
SVE_TRANSLATION_PLACEHOLDER_KEY,
SVE_TRANSLATION_PLACEHOLDER_PROGRAM,
SVE_TRANSLATION_PLACEHOLDER_VALUE,
TEMPERATURE_OPTIONS,
TRANSLATION_KEYS_PROGRAMS_MAP,
VARIO_PERFECT_OPTIONS,
VENTING_LEVEL_OPTIONS,
WARMING_LEVEL_OPTIONS,
)
from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import bsh_key_to_translation_key, get_dict_from_home_connect_error
FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM = {
bsh_key_to_translation_key(option): option
for option in (
"Cooking.Hood.EnumType.ColorTemperature.custom",
"Cooking.Hood.EnumType.ColorTemperature.warm",
"Cooking.Hood.EnumType.ColorTemperature.warmToNeutral",
"Cooking.Hood.EnumType.ColorTemperature.neutral",
"Cooking.Hood.EnumType.ColorTemperature.neutralToCold",
"Cooking.Hood.EnumType.ColorTemperature.cold",
)
}
AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM = {
**{
bsh_key_to_translation_key(option): option
for option in ("BSH.Common.EnumType.AmbientLightColor.CustomColor",)
},
**{
str(option): f"BSH.Common.EnumType.AmbientLightColor.Color{option}"
for option in range(1, 100)
},
}
@dataclass(frozen=True, kw_only=True)
@@ -44,6 +87,14 @@ class HomeConnectProgramSelectEntityDescription(
error_translation_key: str
@dataclass(frozen=True, kw_only=True)
class HomeConnectSelectEntityDescription(SelectEntityDescription):
"""Entity Description class for settings and options that have enumeration values."""
translation_key_values: dict[str, str]
values_translation_key: dict[str, str]
PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
HomeConnectProgramSelectEntityDescription(
key=EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
@@ -65,20 +116,225 @@ PROGRAM_SELECT_ENTITY_DESCRIPTIONS = (
),
)
SELECT_ENTITY_DESCRIPTIONS = (
HomeConnectSelectEntityDescription(
key=SettingKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CURRENT_MAP,
translation_key="current_map",
options=list(AVAILABLE_MAPS_ENUM),
translation_key_values=AVAILABLE_MAPS_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in AVAILABLE_MAPS_ENUM.items()
},
),
HomeConnectSelectEntityDescription(
key=SettingKey.COOKING_HOOD_COLOR_TEMPERATURE,
translation_key="functional_light_color_temperature",
options=list(FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM),
translation_key_values=FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in FUNCTIONAL_LIGHT_COLOR_TEMPERATURE_ENUM.items()
},
),
HomeConnectSelectEntityDescription(
key=SettingKey.BSH_COMMON_AMBIENT_LIGHT_COLOR,
translation_key="ambient_light_color",
options=list(AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM),
translation_key_values=AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in AMBIENT_LIGHT_COLOR_TEMPERATURE_ENUM.items()
},
),
)
PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS = (
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_REFERENCE_MAP_ID,
translation_key="reference_map_id",
options=list(AVAILABLE_MAPS_ENUM),
translation_key_values=AVAILABLE_MAPS_ENUM,
values_translation_key={
value: translation_key
for translation_key, value in AVAILABLE_MAPS_ENUM.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_CLEANING_MODE,
translation_key="cleaning_mode",
options=list(CLEANING_MODE_OPTIONS),
translation_key_values=CLEANING_MODE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in CLEANING_MODE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_AMOUNT,
translation_key="bean_amount",
options=list(BEAN_AMOUNT_OPTIONS),
translation_key_values=BEAN_AMOUNT_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in BEAN_AMOUNT_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_TEMPERATURE,
translation_key="coffee_temperature",
options=list(COFFEE_TEMPERATURE_OPTIONS),
translation_key_values=COFFEE_TEMPERATURE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in COFFEE_TEMPERATURE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEAN_CONTAINER_SELECTION,
translation_key="bean_container",
options=list(BEAN_CONTAINER_OPTIONS),
translation_key_values=BEAN_CONTAINER_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in BEAN_CONTAINER_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_FLOW_RATE,
translation_key="flow_rate",
options=list(FLOW_RATE_OPTIONS),
translation_key_values=FLOW_RATE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in FLOW_RATE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_COFFEE_MILK_RATIO,
translation_key="coffee_milk_ratio",
options=list(COFFEE_MILK_RATIO_OPTIONS),
translation_key_values=COFFEE_MILK_RATIO_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in FLOW_RATE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_HOT_WATER_TEMPERATURE,
translation_key="hot_water_temperature",
options=list(HOT_WATER_TEMPERATURE_OPTIONS),
translation_key_values=HOT_WATER_TEMPERATURE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in HOT_WATER_TEMPERATURE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_DRYER_DRYING_TARGET,
translation_key="drying_target",
options=list(DRYING_TARGET_OPTIONS),
translation_key_values=DRYING_TARGET_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in DRYING_TARGET_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.COOKING_COMMON_HOOD_VENTING_LEVEL,
translation_key="venting_level",
options=list(VENTING_LEVEL_OPTIONS),
translation_key_values=VENTING_LEVEL_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in VENTING_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.COOKING_COMMON_HOOD_INTENSIVE_LEVEL,
translation_key="intensive_level",
options=list(INTENSIVE_LEVEL_OPTIONS),
translation_key_values=INTENSIVE_LEVEL_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in INTENSIVE_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.COOKING_OVEN_WARMING_LEVEL,
translation_key="warming_level",
options=list(WARMING_LEVEL_OPTIONS),
translation_key_values=WARMING_LEVEL_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in WARMING_LEVEL_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE,
translation_key="washer_temperature",
options=list(TEMPERATURE_OPTIONS),
translation_key_values=TEMPERATURE_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in TEMPERATURE_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_SPIN_SPEED,
translation_key="spin_speed",
options=list(SPIN_SPEED_OPTIONS),
translation_key_values=SPIN_SPEED_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in SPIN_SPEED_OPTIONS.items()
},
),
HomeConnectSelectEntityDescription(
key=OptionKey.LAUNDRY_CARE_COMMON_VARIO_PERFECT,
translation_key="vario_perfect",
options=list(VARIO_PERFECT_OPTIONS),
translation_key_values=VARIO_PERFECT_OPTIONS,
values_translation_key={
value: translation_key
for translation_key, value in VARIO_PERFECT_OPTIONS.items()
},
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return (
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
else []
)
return [
*(
[
HomeConnectProgramSelectEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_ENTITY_DESCRIPTIONS
]
if appliance.info.type in APPLIANCES_WITH_PROGRAMS
else []
),
*[
HomeConnectSelectEntity(entry.runtime_data, appliance, desc)
for desc in SELECT_ENTITY_DESCRIPTIONS
if desc.key in appliance.settings
],
]
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectOptionEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(entry.runtime_data, appliance, desc)
for desc in PROGRAM_SELECT_OPTION_ENTITY_DESCRIPTIONS
if desc.key in appliance.options
]
async def async_setup_entry(
@@ -91,6 +347,7 @@ async def async_setup_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
_get_option_entities_for_appliance,
)
@@ -148,3 +405,122 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
SVE_TRANSLATION_PLACEHOLDER_PROGRAM: program_key.value,
},
) from err
class HomeConnectSelectEntity(HomeConnectEntity, SelectEntity):
"""Select setting class for Home Connect."""
entity_description: HomeConnectSelectEntityDescription
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(
coordinator,
appliance,
desc,
)
async def async_select_option(self, option: str) -> None:
"""Select new option."""
value = self.entity_description.translation_key_values[option]
try:
await self.coordinator.client.set_setting(
self.appliance.info.ha_id,
setting_key=cast(SettingKey, self.bsh_key),
value=value,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key=SVE_TRANSLATION_KEY_SET_SETTING,
translation_placeholders={
**get_dict_from_home_connect_error(err),
SVE_TRANSLATION_PLACEHOLDER_ENTITY_ID: self.entity_id,
SVE_TRANSLATION_PLACEHOLDER_KEY: self.bsh_key,
SVE_TRANSLATION_PLACEHOLDER_VALUE: value,
},
) from err
def update_native_value(self) -> None:
"""Set the value of the entity."""
data = self.appliance.settings[cast(SettingKey, self.bsh_key)]
self._attr_current_option = self.entity_description.values_translation_key.get(
data.value
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
setting = self.appliance.settings.get(cast(SettingKey, self.bsh_key))
if (
not setting
or not setting.constraints
or not setting.constraints.allowed_values
):
with contextlib.suppress(HomeConnectError):
setting = await self.coordinator.client.get_setting(
self.appliance.info.ha_id,
setting_key=cast(SettingKey, self.bsh_key),
)
if setting and setting.constraints and setting.constraints.allowed_values:
self._attr_options = [
self.entity_description.values_translation_key[option]
for option in setting.constraints.allowed_values
if option in self.entity_description.values_translation_key
]
class HomeConnectSelectOptionEntity(HomeConnectOptionEntity, SelectEntity):
"""Select option class for Home Connect."""
entity_description: HomeConnectSelectEntityDescription
_original_option_keys: set[str | None]
def __init__(
self,
coordinator: HomeConnectCoordinator,
appliance: HomeConnectApplianceData,
desc: HomeConnectSelectEntityDescription,
) -> None:
"""Initialize the entity."""
self._original_option_keys = set(desc.values_translation_key.keys())
super().__init__(
coordinator,
appliance,
desc,
)
async def async_select_option(self, option: str) -> None:
"""Select new option."""
await self.async_set_option(
self.entity_description.translation_key_values[option]
)
def update_native_value(self) -> None:
"""Set the value of the entity."""
self._attr_current_option = (
self.entity_description.values_translation_key.get(
cast(str, self.option_value), None
)
if self.option_value is not None
else None
)
if (
(option_definition := self.appliance.options.get(self.bsh_key))
and (option_constraints := option_definition.constraints)
and option_constraints.allowed_values
and self._original_option_keys != set(option_constraints.allowed_values)
):
self._original_option_keys = set(option_constraints.allowed_values)
self._attr_options = [
self.entity_description.values_translation_key[option]
for option in self._original_option_keys
if option is not None
]
self.__dict__.pop("options", None)

View File

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

View File

@@ -33,6 +33,9 @@
"appliance_not_found": {
"message": "Appliance for device ID {device_id} not found"
},
"config_entry_not_found": {
"message": "Config entry for device ID {device_id} not found"
},
"turn_on_light": {
"message": "Error turning on {entity_id}: {error}"
},
@@ -98,6 +101,9 @@
},
"required_program_or_one_option_at_least": {
"message": "A program or at least one of the possible options for a program should be specified"
},
"set_option": {
"message": "Error setting the option for the program: {error}"
}
},
"issues": {
@@ -105,6 +111,10 @@
"title": "Deprecated binary door sensor detected in some automations or scripts",
"description": "The binary door sensor `{entity}`, which is deprecated, is used in the following automations or scripts:\n{items}\n\nA sensor entity with additional possible states is available and should be used going forward; Please use it on the above automations or scripts to fix this issue."
},
"deprecated_command_actions": {
"title": "The command related actions are deprecated in favor of the new buttons",
"description": "The `pause_program` and `resume_program` actions have been deprecated in favor of new button entities, if the command is available for your appliance. Please update your automations, scripts and panels that use this action to use the button entities instead, and press on submit to fix the issue."
},
"deprecated_program_switch": {
"title": "Deprecated program switch detected in some automations or scripts",
"description": "Program switches are deprecated and {entity_id} is used in the following automations or scripts:\n{items}\n\nYou can use the active program select entity to run the program without any additional options and get the current running program on the above automations or scripts to fix this issue."
@@ -812,6 +822,23 @@
"name": "Wine compartment door"
}
},
"button": {
"open_door": {
"name": "Open door"
},
"partly_open_door": {
"name": "Partly open door"
},
"pause_program": {
"name": "Pause program"
},
"resume_program": {
"name": "Resume program"
},
"stop_program": {
"name": "Stop program"
}
},
"light": {
"cooking_lighting": {
"name": "Functional light"
@@ -854,11 +881,29 @@
"wine_compartment_3_setpoint_temperature": {
"name": "Wine compartment 3 temperature"
},
"color_temperature_percent": {
"name": "Functional light color temperature percent"
},
"washer_i_dos_1_base_level": {
"name": "i-Dos 1 base level"
},
"washer_i_dos_2_base_level": {
"name": "i-Dos 2 base level"
},
"duration": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_duration::name%]"
},
"start_in_relative": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_start_in_relative::name%]"
},
"finish_in_relative": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::b_s_h_common_option_finish_in_relative::name%]"
},
"fill_quantity": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_fill_quantity::name%]"
},
"setpoint_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_setpoint_temperature::name%]"
}
},
"select": {
@@ -1179,6 +1224,226 @@
"laundry_care_washer_dryer_program_wash_and_dry_60": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_60%]",
"laundry_care_washer_dryer_program_wash_and_dry_90": "[%key:component::home_connect::selector::programs::options::laundry_care_washer_dryer_program_wash_and_dry_90%]"
}
},
"current_map": {
"name": "Current map",
"state": {
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
}
},
"functional_light_color_temperature": {
"name": "Functional light color temperature",
"state": {
"cooking_hood_enum_type_color_temperature_custom": "Custom",
"cooking_hood_enum_type_color_temperature_warm": "Warm",
"cooking_hood_enum_type_color_temperature_warm_to_neutral": "Warm to Neutral",
"cooking_hood_enum_type_color_temperature_neutral": "Neutral",
"cooking_hood_enum_type_color_temperature_neutral_to_cold": "Neutral to Cold",
"cooking_hood_enum_type_color_temperature_cold": "Cold"
}
},
"ambient_light_color": {
"name": "Ambient light color",
"state": {
"b_s_h_common_enum_type_ambient_light_color_custom_color": "Custom"
}
},
"reference_map_id": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]"
}
},
"cleaning_mode": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_cleaning_mode::name%]",
"state": {
"consumer_products_cleaning_robot_enum_type_cleaning_modes_silent": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_silent%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_standard": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_standard%]",
"consumer_products_cleaning_robot_enum_type_cleaning_modes_power": "[%key:component::home_connect::selector::cleaning_mode::options::consumer_products_cleaning_robot_enum_type_cleaning_modes_power%]"
}
},
"bean_amount": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_amount::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_bean_amount_very_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_mild%]",
"consumer_products_coffee_maker_enum_type_bean_amount_mild": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild%]",
"consumer_products_coffee_maker_enum_type_bean_amount_mild_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_mild_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_normal": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal%]",
"consumer_products_coffee_maker_enum_type_bean_amount_normal_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_normal_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong%]",
"consumer_products_coffee_maker_enum_type_bean_amount_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_strong_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_very_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong%]",
"consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_very_strong_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_extra_strong": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_extra_strong%]",
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot%]",
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_double_shot_plus_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_triple_shot": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot%]",
"consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_triple_shot_plus%]",
"consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground": "[%key:component::home_connect::selector::bean_amount::options::consumer_products_coffee_maker_enum_type_bean_amount_coffee_ground%]"
}
},
"coffee_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_temperature::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_coffee_temperature_88_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_88_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_90_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_90_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_92_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_92_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_94_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_94_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_95_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_95_c%]",
"consumer_products_coffee_maker_enum_type_coffee_temperature_96_c": "[%key:component::home_connect::selector::coffee_temperature::options::consumer_products_coffee_maker_enum_type_coffee_temperature_96_c%]"
}
},
"bean_container": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]",
"consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]"
}
},
"flow_rate": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_flow_rate::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_flow_rate_normal": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_normal%]",
"consumer_products_coffee_maker_enum_type_flow_rate_intense": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense%]",
"consumer_products_coffee_maker_enum_type_flow_rate_intense_plus": "[%key:component::home_connect::selector::flow_rate::options::consumer_products_coffee_maker_enum_type_flow_rate_intense_plus%]"
}
},
"coffee_milk_ratio": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_coffee_milk_ratio::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_10_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_20_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_25_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_30_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_40_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_50_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_55_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_60_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_65_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_67_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_70_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_75_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_80_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_85_percent%]",
"consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent": "[%key:component::home_connect::selector::coffee_milk_ratio::options::consumer_products_coffee_maker_enum_type_coffee_milk_ratio_90_percent%]"
}
},
"hot_water_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_hot_water_temperature::name%]",
"state": {
"consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_white_tea%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_green_tea%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_black_tea%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_50_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_55_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_60_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_65_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_70_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_75_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_80_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_85_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_90_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_95_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_97_c%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_122_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_131_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_140_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_149_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_158_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_167_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_176_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_185_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_194_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_203_f%]",
"consumer_products_coffee_maker_enum_type_hot_water_temperature_max": "[%key:component::home_connect::selector::hot_water_temperature::options::consumer_products_coffee_maker_enum_type_hot_water_temperature_max%]"
}
},
"drying_target": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_dryer_option_drying_target::name%]",
"state": {
"laundry_care_dryer_enum_type_drying_target_iron_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_iron_dry%]",
"laundry_care_dryer_enum_type_drying_target_gentle_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_gentle_dry%]",
"laundry_care_dryer_enum_type_drying_target_cupboard_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry%]",
"laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_cupboard_dry_plus%]",
"laundry_care_dryer_enum_type_drying_target_extra_dry": "[%key:component::home_connect::selector::drying_target::options::laundry_care_dryer_enum_type_drying_target_extra_dry%]"
}
},
"venting_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
"state": {
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
"cooking_hood_enum_type_stage_fan_stage01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage01%]",
"cooking_hood_enum_type_stage_fan_stage02": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage02%]",
"cooking_hood_enum_type_stage_fan_stage03": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage03%]",
"cooking_hood_enum_type_stage_fan_stage04": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage04%]",
"cooking_hood_enum_type_stage_fan_stage05": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage05%]"
}
},
"intensive_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
"state": {
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]"
}
},
"warming_level": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_warming_level::name%]",
"state": {
"cooking_oven_enum_type_warming_level_low": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_low%]",
"cooking_oven_enum_type_warming_level_medium": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_medium%]",
"cooking_oven_enum_type_warming_level_high": "[%key:component::home_connect::selector::warming_level::options::cooking_oven_enum_type_warming_level_high%]"
}
},
"washer_temperature": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_temperature::name%]",
"state": {
"laundry_care_washer_enum_type_temperature_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_cold%]",
"laundry_care_washer_enum_type_temperature_g_c20": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c20%]",
"laundry_care_washer_enum_type_temperature_g_c30": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c30%]",
"laundry_care_washer_enum_type_temperature_g_c40": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c40%]",
"laundry_care_washer_enum_type_temperature_g_c50": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c50%]",
"laundry_care_washer_enum_type_temperature_g_c60": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c60%]",
"laundry_care_washer_enum_type_temperature_g_c70": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c70%]",
"laundry_care_washer_enum_type_temperature_g_c80": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c80%]",
"laundry_care_washer_enum_type_temperature_g_c90": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_g_c90%]",
"laundry_care_washer_enum_type_temperature_ul_cold": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_cold%]",
"laundry_care_washer_enum_type_temperature_ul_warm": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_warm%]",
"laundry_care_washer_enum_type_temperature_ul_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_hot%]",
"laundry_care_washer_enum_type_temperature_ul_extra_hot": "[%key:component::home_connect::selector::washer_temperature::options::laundry_care_washer_enum_type_temperature_ul_extra_hot%]"
}
},
"spin_speed": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_spin_speed::name%]",
"state": {
"laundry_care_washer_enum_type_spin_speed_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_off%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m600%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m800": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m800%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1000": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1000%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1200": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1200%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1400": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1400%]",
"laundry_care_washer_enum_type_spin_speed_r_p_m1600": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_r_p_m1600%]",
"laundry_care_washer_enum_type_spin_speed_ul_off": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_off%]",
"laundry_care_washer_enum_type_spin_speed_ul_low": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_low%]",
"laundry_care_washer_enum_type_spin_speed_ul_medium": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_medium%]",
"laundry_care_washer_enum_type_spin_speed_ul_high": "[%key:component::home_connect::selector::spin_speed::options::laundry_care_washer_enum_type_spin_speed_ul_high%]"
}
},
"vario_perfect": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
"state": {
"laundry_care_common_enum_type_vario_perfect_off": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_off%]",
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
"laundry_care_common_enum_type_vario_perfect_speed_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_speed_perfect%]"
}
}
},
"sensor": {
@@ -1365,6 +1630,45 @@
},
"door_assistant_freezer": {
"name": "Freezer door assistant"
},
"multiple_beverages": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_multiple_beverages::name%]"
},
"intensiv_zone": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
},
"brilliance_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_brilliance_dry::name%]"
},
"vario_speed_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_vario_speed_plus::name%]"
},
"silence_on_demand": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_silence_on_demand::name%]"
},
"half_load": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_half_load::name%]"
},
"extra_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_extra_dry::name%]"
},
"hygiene_plus": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
},
"eco_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_eco_dry::name%]"
},
"zeolite_dry": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_zeolite_dry::name%]"
},
"fast_pre_heat": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_oven_option_fast_pre_heat::name%]"
},
"i_dos1_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
},
"i_dos2_active": {
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
}
},
"time": {

View File

@@ -3,7 +3,7 @@
import logging
from typing import Any, cast
from aiohomeconnect.model import EventKey, ProgramKey, SettingKey
from aiohomeconnect.model import EventKey, OptionKey, ProgramKey, SettingKey
from aiohomeconnect.model.error import HomeConnectError
from aiohomeconnect.model.program import EnumerateProgram
@@ -37,7 +37,7 @@ from .coordinator import (
HomeConnectConfigEntry,
HomeConnectCoordinator,
)
from .entity import HomeConnectEntity
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
@@ -100,6 +100,61 @@ POWER_SWITCH_DESCRIPTION = SwitchEntityDescription(
translation_key="power",
)
SWITCH_OPTIONS = (
SwitchEntityDescription(
key=OptionKey.CONSUMER_PRODUCTS_COFFEE_MAKER_MULTIPLE_BEVERAGES,
translation_key="multiple_beverages",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_INTENSIV_ZONE,
translation_key="intensiv_zone",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_BRILLIANCE_DRY,
translation_key="brilliance_dry",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_VARIO_SPEED_PLUS,
translation_key="vario_speed_plus",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_SILENCE_ON_DEMAND,
translation_key="silence_on_demand",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_HALF_LOAD,
translation_key="half_load",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_EXTRA_DRY,
translation_key="extra_dry",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_HYGIENE_PLUS,
translation_key="hygiene_plus",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_ECO_DRY,
translation_key="eco_dry",
),
SwitchEntityDescription(
key=OptionKey.DISHCARE_DISHWASHER_ZEOLITE_DRY,
translation_key="zeolite_dry",
),
SwitchEntityDescription(
key=OptionKey.COOKING_OVEN_FAST_PRE_HEAT,
translation_key="fast_pre_heat",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE,
translation_key="i_dos1_active",
),
SwitchEntityDescription(
key=OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE,
translation_key="i_dos2_active",
),
)
def _get_entities_for_appliance(
entry: HomeConnectConfigEntry,
@@ -123,10 +178,21 @@ def _get_entities_for_appliance(
for description in SWITCHES
if description.key in appliance.settings
)
return entities
def _get_option_entities_for_appliance(
entry: HomeConnectConfigEntry,
appliance: HomeConnectApplianceData,
) -> list[HomeConnectOptionEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(entry.runtime_data, appliance, description)
for description in SWITCH_OPTIONS
if description.key in appliance.options
]
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
@@ -137,6 +203,7 @@ async def async_setup_entry(
entry,
_get_entities_for_appliance,
async_add_entities,
_get_option_entities_for_appliance,
)
@@ -403,3 +470,19 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity):
self.power_off_state = BSH_POWER_STANDBY
else:
self.power_off_state = None
class HomeConnectSwitchOptionEntity(HomeConnectOptionEntity, SwitchEntity):
"""Switch option class for Home Connect."""
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on the option."""
await self.async_set_option(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the option."""
await self.async_set_option(False)
def update_native_value(self) -> None:
"""Set the value of the entity."""
self._attr_is_on = cast(bool | None, self.option_value)

View File

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

View File

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

View File

@@ -44,7 +44,7 @@
"fields": {
"cycle": {
"name": "[%key:component::input_select::services::select_next::fields::cycle::name%]",
"description": "[%key:component::input_select::services::select_next::fields::cycle::description%]"
"description": "If the option should cycle from the first to the last option on the list."
}
}
},

View File

@@ -49,6 +49,7 @@ from .helpers import (
InputType,
async_update_config_entry,
generate_unique_id,
purge_device_registry,
register_lcn_address_devices,
register_lcn_host_device,
)
@@ -120,6 +121,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
register_lcn_host_device(hass, config_entry)
register_lcn_address_devices(hass, config_entry)
# clean up orphaned devices
purge_device_registry(hass, config_entry.entry_id, {**config_entry.data})
# forward config_entry to components
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)

View File

@@ -3,19 +3,18 @@
from collections.abc import Callable
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_DOMAIN, CONF_NAME, CONF_RESOURCE
from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_RESOURCE
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType
from .const import CONF_DOMAIN_DATA, DOMAIN
from .const import DOMAIN
from .helpers import (
AddressType,
DeviceConnectionType,
InputType,
generate_unique_id,
get_device_connection,
get_device_model,
)
@@ -36,6 +35,14 @@ class LcnEntity(Entity):
self.address: AddressType = config[CONF_ADDRESS]
self._unregister_for_inputs: Callable | None = None
self._name: str = config[CONF_NAME]
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
generate_unique_id(self.config_entry.entry_id, self.address),
)
},
)
@property
def unique_id(self) -> str:
@@ -44,28 +51,6 @@ class LcnEntity(Entity):
self.config_entry.entry_id, self.address, self.config[CONF_RESOURCE]
)
@property
def device_info(self) -> DeviceInfo | None:
"""Return device specific attributes."""
address = f"{'g' if self.address[2] else 'm'}{self.address[0]:03d}{self.address[1]:03d}"
model = (
"LCN resource"
f" ({get_device_model(self.config[CONF_DOMAIN], self.config[CONF_DOMAIN_DATA])})"
)
return DeviceInfo(
identifiers={(DOMAIN, self.unique_id)},
name=f"{address}.{self.config[CONF_RESOURCE]}",
model=model,
manufacturer="Issendorff",
via_device=(
DOMAIN,
generate_unique_id(
self.config_entry.entry_id, self.config[CONF_ADDRESS]
),
),
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
self.device_connection = get_device_connection(

View File

@@ -4,7 +4,6 @@ from __future__ import annotations
import asyncio
from copy import deepcopy
from itertools import chain
import re
from typing import cast
@@ -22,7 +21,6 @@ from homeassistant.const import (
CONF_NAME,
CONF_RESOURCE,
CONF_SENSORS,
CONF_SOURCE,
CONF_SWITCHES,
)
from homeassistant.core import HomeAssistant
@@ -30,23 +28,14 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import (
BINSENSOR_PORTS,
CONF_CLIMATES,
CONF_HARDWARE_SERIAL,
CONF_HARDWARE_TYPE,
CONF_OUTPUT,
CONF_SCENES,
CONF_SOFTWARE_SERIAL,
CONNECTION,
DEVICE_CONNECTIONS,
DOMAIN,
LED_PORTS,
LOGICOP_PORTS,
OUTPUT_PORTS,
S0_INPUTS,
SETPOINTS,
THRESHOLDS,
VARIABLES,
)
# typing
@@ -96,31 +85,6 @@ def get_resource(domain_name: str, domain_data: ConfigType) -> str:
raise ValueError("Unknown domain")
def get_device_model(domain_name: str, domain_data: ConfigType) -> str:
"""Return the model for the specified domain_data."""
if domain_name in ("switch", "light"):
return "Output" if domain_data[CONF_OUTPUT] in OUTPUT_PORTS else "Relay"
if domain_name in ("binary_sensor", "sensor"):
if domain_data[CONF_SOURCE] in BINSENSOR_PORTS:
return "Binary Sensor"
if domain_data[CONF_SOURCE] in chain(
VARIABLES, SETPOINTS, THRESHOLDS, S0_INPUTS
):
return "Variable"
if domain_data[CONF_SOURCE] in LED_PORTS:
return "Led"
if domain_data[CONF_SOURCE] in LOGICOP_PORTS:
return "Logical Operation"
return "Key"
if domain_name == "cover":
return "Motor"
if domain_name == "climate":
return "Regulator"
if domain_name == "scene":
return "Scene"
raise ValueError("Unknown domain")
def generate_unique_id(
entry_id: str,
address: AddressType,
@@ -169,13 +133,6 @@ def purge_device_registry(
) -> None:
"""Remove orphans from device registry which are not in entry data."""
device_registry = dr.async_get(hass)
entity_registry = er.async_get(hass)
# Find all devices that are referenced in the entity registry.
references_entities = {
entry.device_id
for entry in entity_registry.entities.get_entries_for_config_entry_id(entry_id)
}
# Find device that references the host.
references_host = set()
@@ -198,7 +155,6 @@ def purge_device_registry(
entry.id
for entry in dr.async_entries_for_config_entry(device_registry, entry_id)
}
- references_entities
- references_host
- references_entry_data
)

View File

@@ -118,16 +118,7 @@ DEVICE_TYPE_NUMBER_MAP: dict[DeviceType, tuple[NumberEntityDescription, ...]] =
DeviceType.WASHTOWER_DRYER: WASHER_NUMBERS,
DeviceType.WASHTOWER: WASHER_NUMBERS,
DeviceType.WASHTOWER_WASHER: WASHER_NUMBERS,
DeviceType.WATER_HEATER: (
NumberEntityDescription(
key=ThinQProperty.TARGET_TEMPERATURE,
native_max_value=60,
native_min_value=35,
native_step=1,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
translation_key=ThinQProperty.TARGET_TEMPERATURE,
),
),
DeviceType.WATER_HEATER: (NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],),
DeviceType.WINE_CELLAR: (
NUMBER_DESC[ThinQProperty.LIGHT_STATUS],
NUMBER_DESC[ThinQProperty.TARGET_TEMPERATURE],
@@ -179,7 +170,7 @@ class ThinQNumberEntity(ThinQEntity, NumberEntity):
) is not None:
self._attr_native_unit_of_measurement = unit_of_measurement
# Undate range.
# Update range.
if (
self.entity_description.native_min_value is None
and (min_value := self.data.min) is not None

View File

@@ -581,36 +581,44 @@ class ThinQSensorEntity(ThinQEntity, SensorEntity):
local_now = datetime.now(
tz=dt_util.get_time_zone(self.coordinator.hass.config.time_zone)
)
if value in [0, None, time.min]:
# Reset to None
self._device_state = (
self.coordinator.data[self._device_state_id].value
if self._device_state_id in self.coordinator.data
else None
)
if value in [0, None, time.min] or (
self._device_state == "power_off"
and self.entity_description.key
in [TimerProperty.REMAIN, TimerProperty.TOTAL]
):
# Reset to None when power_off
value = None
elif self.entity_description.device_class == SensorDeviceClass.TIMESTAMP:
if self.entity_description.key in TIME_SENSOR_DESC:
# Set timestamp for time
# Set timestamp for absolute 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
if self._device_state_id in self.coordinator.data
else None
)
if (
self.native_value is not None
and self._device_state == new_state
):
# Skip update when same state
return
self._device_state = new_state
time_delta = timedelta(
event_data = timedelta(
hours=value.hour, minutes=value.minute, seconds=value.second
)
value = (
(local_now - time_delta)
new_time = (
(local_now - event_data)
if self.entity_description.key == TimerProperty.RUNNING
else (local_now + time_delta)
else (local_now + event_data)
)
# The remain_time may change during the wash/dry operation depending on various reasons.
# If there is a diff of more than 60sec, the new timestamp is used
if (
parse_native_value := dt_util.parse_datetime(
str(self.native_value)
)
) is None or abs(new_time - parse_native_value) > timedelta(
seconds=60
):
value = new_time
else:
value = self.native_value
elif self.entity_description.device_class == SensorDeviceClass.DURATION:
# Set duration
value = self._get_duration(

View File

@@ -25,10 +25,12 @@ MODELS_ARYLIC_A30: Final[str] = "A30"
MODELS_ARYLIC_A50: Final[str] = "A50"
MODELS_ARYLIC_A50S: Final[str] = "A50+"
MODELS_ARYLIC_UP2STREAM_AMP: Final[str] = "Up2Stream Amp 2.0"
MODELS_ARYLIC_UP2STREAM_AMP_2P1: Final[str] = "Up2Stream Amp 2.1"
MODELS_ARYLIC_UP2STREAM_AMP_V3: Final[str] = "Up2Stream Amp v3"
MODELS_ARYLIC_UP2STREAM_AMP_V4: Final[str] = "Up2Stream Amp v4"
MODELS_ARYLIC_UP2STREAM_PRO: Final[str] = "Up2Stream Pro v1"
MODELS_ARYLIC_UP2STREAM_PRO_V3: Final[str] = "Up2Stream Pro v3"
MODELS_ARYLIC_S10P: Final[str] = "Arylic S10+"
MODELS_ARYLIC_UP2STREAM_PLATE_AMP: Final[str] = "Up2Stream Plate Amp"
MODELS_IEAST_AUDIOCAST_M5: Final[str] = "AudioCast M5"
MODELS_WIIM_AMP: Final[str] = "WiiM Amp"
@@ -49,9 +51,10 @@ PROJECTID_LOOKUP: Final[dict[str, tuple[str, str]]] = {
"UP2STREAM_AMP_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V3),
"UP2STREAM_AMP_V4": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_V4),
"UP2STREAM_PRO_V3": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PRO_V3),
"S10P_WIFI": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_S10P),
"ARYLIC_V20": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_PLATE_AMP),
"UP2STREAM_MINI_V3": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"UP2STREAM_AMP_2P1": (MANUFACTURER_ARYLIC, MODELS_ARYLIC_UP2STREAM_AMP_2P1),
"RP0014_A50C_S": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_A30": (MANUFACTURER_ARYLIC, MODELS_GENERIC),
"ARYLIC_SUBWOOFER": (MANUFACTURER_ARYLIC, MODELS_GENERIC),

View File

@@ -152,6 +152,14 @@ class AtaDeviceClimate(MelCloudClimate):
self._attr_unique_id = f"{self.api.device.serial}-{self.api.device.mac}"
self._attr_device_info = self.api.device_info
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
# We can only check for vane_horizontal once we fetch the device data from the cloud
if self._device.vane_horizontal:
self._attr_supported_features |= ClimateEntityFeature.SWING_HORIZONTAL_MODE
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the optional state attributes with device specific additions."""
@@ -274,15 +282,29 @@ class AtaDeviceClimate(MelCloudClimate):
"""Return vertical vane position or mode."""
return self._device.vane_vertical
@property
def swing_horizontal_mode(self) -> str | None:
"""Return horizontal vane position or mode."""
return self._device.vane_horizontal
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set vertical vane position or mode."""
await self.async_set_vane_vertical(swing_mode)
async def async_set_swing_horizontal_mode(self, swing_horizontal_mode: str) -> None:
"""Set horizontal vane position or mode."""
await self.async_set_vane_horizontal(swing_horizontal_mode)
@property
def swing_modes(self) -> list[str] | None:
"""Return a list of available vertical vane positions and modes."""
return self._device.vane_vertical_positions
@property
def swing_horizontal_modes(self) -> list[str] | None:
"""Return a list of available horizontal vane positions and modes."""
return self._device.vane_horizontal_positions
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self._device.set({"power": True})

View File

@@ -9,15 +9,13 @@ import dns.rdata
import dns.rdataclass
import dns.rdatatype
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, CONF_TYPE, Platform
from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType
from .const import DOMAIN, KEY_LATENCY, KEY_MOTD
from .coordinator import MinecraftServerCoordinator
from .coordinator import MinecraftServerConfigEntry, MinecraftServerCoordinator
PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -31,32 +29,18 @@ def load_dnspython_rdata_classes() -> None:
dns.rdata.get_rdata_class(dns.rdataclass.IN, rdtype) # type: ignore[no-untyped-call]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: MinecraftServerConfigEntry
) -> bool:
"""Set up Minecraft Server from a config entry."""
# Workaround to avoid blocking imports from dnspython (https://github.com/rthalley/dnspython/issues/1083)
await hass.async_add_executor_job(load_dnspython_rdata_classes)
# Create API instance.
api = MinecraftServer(
hass,
entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
entry.data[CONF_ADDRESS],
)
# Initialize API instance.
try:
await api.async_initialize()
except MinecraftServerAddressError as error:
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
# Create coordinator instance.
coordinator = MinecraftServerCoordinator(hass, entry, api)
# Create coordinator instance and store it.
coordinator = MinecraftServerCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
# Store coordinator instance.
domain_data = hass.data.setdefault(DOMAIN, {})
domain_data[entry.entry_id] = coordinator
entry.runtime_data = coordinator
# Set up platforms.
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -64,21 +48,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
) -> bool:
"""Unload Minecraft Server config entry."""
# Unload platforms.
unload_ok = await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
# Clean up.
hass.data[DOMAIN].pop(config_entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_migrate_entry(
hass: HomeAssistant, config_entry: MinecraftServerConfigEntry
) -> bool:
"""Migrate old config entry to a new format."""
# 1 --> 2: Use config entry ID as base for unique IDs.
@@ -152,7 +131,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
async def _async_migrate_device_identifiers(
hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None
hass: HomeAssistant,
config_entry: MinecraftServerConfigEntry,
old_unique_id: str | None,
) -> None:
"""Migrate the device identifiers to the new format."""
device_registry = dr.async_get(hass)

View File

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

View File

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

View File

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

View File

@@ -6,17 +6,22 @@ from datetime import timedelta
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.const import CONF_ADDRESS, CONF_TYPE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .api import (
MinecraftServer,
MinecraftServerAddressError,
MinecraftServerConnectionError,
MinecraftServerData,
MinecraftServerNotInitializedError,
MinecraftServerType,
)
type MinecraftServerConfigEntry = ConfigEntry[MinecraftServerCoordinator]
SCAN_INTERVAL = timedelta(seconds=60)
_LOGGER = logging.getLogger(__name__)
@@ -25,25 +30,40 @@ _LOGGER = logging.getLogger(__name__)
class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]):
"""Minecraft Server data update coordinator."""
config_entry: ConfigEntry
config_entry: MinecraftServerConfigEntry
_api: MinecraftServer
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
api: MinecraftServer,
config_entry: MinecraftServerConfigEntry,
) -> None:
"""Initialize coordinator instance."""
self._api = api
super().__init__(
hass=hass,
name=config_entry.data[CONF_NAME],
name=config_entry.title,
config_entry=config_entry,
logger=_LOGGER,
update_interval=SCAN_INTERVAL,
)
async def _async_setup(self) -> None:
"""Set up the Minecraft Server data coordinator."""
# Create API instance.
self._api = MinecraftServer(
self.hass,
self.config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION),
self.config_entry.data[CONF_ADDRESS],
)
# Initialize API instance.
try:
await self._api.async_initialize()
except MinecraftServerAddressError as error:
raise ConfigEntryNotReady(f"Initialization failed: {error}") from error
async def _async_update_data(self) -> MinecraftServerData:
"""Get updated data from the server."""
try:

View File

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

View File

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

View File

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

View File

@@ -2,12 +2,14 @@
"config": {
"step": {
"user": {
"title": "Link your Minecraft Server",
"description": "Set up your Minecraft Server instance to allow monitoring.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
"address": "Server address"
}
},
"data_description": {
"address": "The hostname, IP address or SRV record of your Minecraft server, optionally including the port."
},
"title": "Link your Minecraft Server",
"description": "Set up your Minecraft Server instance to allow monitoring."
}
},
"abort": {

View File

@@ -384,6 +384,11 @@ class ModbusHub:
{ATTR_SLAVE: slave} if slave is not None else {ATTR_SLAVE: 1}
)
entry = self._pb_request[use_call]
if use_call in {"write_registers", "write_coils"}:
if not isinstance(value, list):
value = [value]
kwargs[entry.value_attr_name] = value
try:
result: ModbusPDU = await entry.func(address, **kwargs)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,7 +29,6 @@ from homeassistant.components.http.view import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import area_registry as ar
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.system_info import async_get_system_info
from homeassistant.helpers.translation import async_get_translations
from homeassistant.setup import async_setup_component
@@ -224,16 +223,6 @@ class CoreConfigOnboardingView(_BaseOnboardingView):
"shopping_list",
]
# pylint: disable-next=import-outside-toplevel
from homeassistant.components import hassio
if (
is_hassio(hass)
and (core_info := hassio.get_core_info(hass))
and "raspberrypi" in core_info["machine"]
):
onboard_integrations.append("rpi_power")
for domain in onboard_integrations:
# Create tasks so onboarding isn't affected
# by errors in these integrations.

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from html import unescape
from json import dumps, loads
import logging
@@ -10,13 +11,13 @@ from typing import cast
from onedrive_personal_sdk import OneDriveClient
from onedrive_personal_sdk.exceptions import (
AuthenticationError,
HttpRequestException,
NotFoundError,
OneDriveException,
)
from onedrive_personal_sdk.models.items import ItemUpdate
from onedrive_personal_sdk.models.items import Item, ItemUpdate
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -25,7 +26,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .const import CONF_FOLDER_ID, CONF_FOLDER_NAME, DATA_BACKUP_AGENT_LISTENERS, DOMAIN
from .coordinator import (
OneDriveConfigEntry,
OneDriveRuntimeData,
@@ -50,33 +51,38 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
client = OneDriveClient(get_access_token, async_get_clientsession(hass))
# get approot, will be created automatically if it does not exist
try:
approot = await client.get_approot()
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (HttpRequestException, OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to get approot", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": "approot"},
) from err
approot = await _handle_item_operation(client.get_approot, "approot")
folder_name = entry.data[CONF_FOLDER_NAME]
instance_id = await async_get_instance_id(hass)
backup_folder_name = f"backups_{instance_id[:8]}"
try:
backup_folder = await client.create_folder(
parent_id=approot.id, name=backup_folder_name
backup_folder = await _handle_item_operation(
lambda: client.get_drive_item(path_or_id=entry.data[CONF_FOLDER_ID]),
folder_name,
)
except NotFoundError:
_LOGGER.debug("Creating backup folder %s", folder_name)
backup_folder = await _handle_item_operation(
lambda: client.create_folder(parent_id=approot.id, name=folder_name),
folder_name,
)
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_FOLDER_ID: backup_folder.id}
)
# write instance id to description
if backup_folder.description != (instance_id := await async_get_instance_id(hass)):
await _handle_item_operation(
lambda: client.update_drive_item(
backup_folder.id, ItemUpdate(description=instance_id)
),
folder_name,
)
# update in case folder was renamed manually inside OneDrive
if backup_folder.name != entry.data[CONF_FOLDER_NAME]:
hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_FOLDER_NAME: backup_folder.name}
)
except (HttpRequestException, OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to create backup folder", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": backup_folder_name},
) from err
coordinator = OneDriveUpdateCoordinator(hass, entry, client)
await coordinator.async_config_entry_first_refresh()
@@ -96,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
translation_key="failed_to_migrate_files",
) from err
_async_notify_backup_listeners_soon(hass)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
async def update_listener(hass: HomeAssistant, entry: OneDriveConfigEntry) -> None:
@@ -104,25 +109,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) ->
entry.async_on_unload(entry.add_update_listener(update_listener))
def async_notify_backup_listeners() -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
entry.async_on_unload(entry.async_on_state_change(async_notify_backup_listeners))
return True
async def async_unload_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Unload a OneDrive config entry."""
_async_notify_backup_listeners_soon(hass)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
def _async_notify_backup_listeners(hass: HomeAssistant) -> None:
for listener in hass.data.get(DATA_BACKUP_AGENT_LISTENERS, []):
listener()
@callback
def _async_notify_backup_listeners_soon(hass: HomeAssistant) -> None:
hass.loop.call_soon(_async_notify_backup_listeners, hass)
async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -> None:
"""Migrate backup files to metadata version 2."""
files = await client.list_drive_items(backup_folder_id)
@@ -152,3 +152,47 @@ async def _migrate_backup_files(client: OneDriveClient, backup_folder_id: str) -
data=ItemUpdate(description=""),
)
_LOGGER.debug("Migrated backup file %s", file.name)
async def async_migrate_entry(hass: HomeAssistant, entry: OneDriveConfigEntry) -> bool:
"""Migrate old entry."""
if entry.version > 1:
# This means the user has downgraded from a future version
return False
if (version := entry.version) == 1 and (minor_version := entry.minor_version) == 1:
_LOGGER.debug(
"Migrating OneDrive config entry from version %s.%s", version, minor_version
)
instance_id = await async_get_instance_id(hass)
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_FOLDER_ID: "id", # will be updated during setup_entry
CONF_FOLDER_NAME: f"backups_{instance_id[:8]}",
},
)
_LOGGER.debug("Migration to version 1.2 successful")
return True
async def _handle_item_operation(
func: Callable[[], Awaitable[Item]], folder: str
) -> Item:
try:
return await func()
except NotFoundError:
raise
except AuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN, translation_key="authentication_failed"
) from err
except (OneDriveException, TimeoutError) as err:
_LOGGER.debug("Failed to get approot", exc_info=True)
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="failed_to_get_folder",
translation_placeholders={"folder": folder},
) from err

View File

@@ -74,7 +74,7 @@ def async_register_backup_agents_listener(
def handle_backup_errors[_R, **P](
func: Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]],
) -> Callable[Concatenate[OneDriveBackupAgent, P], Coroutine[Any, Any, _R]]:
"""Handle backup errors with a specific translation key."""
"""Handle backup errors."""
@wraps(func)
async def wrapper(

View File

@@ -8,22 +8,47 @@ from typing import Any, cast
from onedrive_personal_sdk.clients.client import OneDriveClient
from onedrive_personal_sdk.exceptions import OneDriveException
from onedrive_personal_sdk.models.items import AppRoot, ItemUpdate
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult, OptionsFlow
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlowResult,
OptionsFlow,
)
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
from .const import CONF_DELETE_PERMANENTLY, DOMAIN, OAUTH_SCOPES
from .const import (
CONF_DELETE_PERMANENTLY,
CONF_FOLDER_ID,
CONF_FOLDER_NAME,
DOMAIN,
OAUTH_SCOPES,
)
from .coordinator import OneDriveConfigEntry
FOLDER_NAME_SCHEMA = vol.Schema({vol.Required(CONF_FOLDER_NAME): str})
class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Config flow to handle OneDrive OAuth2 authentication."""
DOMAIN = DOMAIN
MINOR_VERSION = 2
client: OneDriveClient
approot: AppRoot
def __init__(self) -> None:
"""Initialize the OneDrive config flow."""
super().__init__()
self.step_data: dict[str, Any] = {}
@property
def logger(self) -> logging.Logger:
@@ -35,6 +60,15 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(OAUTH_SCOPES)}
@property
def apps_folder(self) -> str:
"""Return the name of the Apps folder (translated)."""
return (
path.split("/")[-1]
if (path := self.approot.parent_reference.path)
else "Apps"
)
async def async_oauth_create_entry(
self,
data: dict[str, Any],
@@ -44,12 +78,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
async def get_access_token() -> str:
return cast(str, data[CONF_TOKEN][CONF_ACCESS_TOKEN])
graph_client = OneDriveClient(
self.client = OneDriveClient(
get_access_token, async_get_clientsession(self.hass)
)
try:
approot = await graph_client.get_approot()
self.approot = await self.client.get_approot()
except OneDriveException:
self.logger.exception("Failed to connect to OneDrive")
return self.async_abort(reason="connection_error")
@@ -57,26 +91,118 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
self.logger.exception("Unknown error")
return self.async_abort(reason="unknown")
await self.async_set_unique_id(approot.parent_reference.drive_id)
await self.async_set_unique_id(self.approot.parent_reference.drive_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
if self.source != SOURCE_USER:
self._abort_if_unique_id_mismatch(
reason="wrong_drive",
)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
entry=reauth_entry,
data=data,
)
self._abort_if_unique_id_configured()
if self.source != SOURCE_RECONFIGURE:
self._abort_if_unique_id_configured()
title = (
f"{approot.created_by.user.display_name}'s OneDrive"
if approot.created_by.user and approot.created_by.user.display_name
else "OneDrive"
self.step_data = data
if self.source == SOURCE_RECONFIGURE:
return await self.async_step_reconfigure_folder()
return await self.async_step_folder_name()
async def async_step_folder_name(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step to ask for the folder name."""
errors: dict[str, str] = {}
instance_id = await async_get_instance_id(self.hass)
if user_input is not None:
try:
folder = await self.client.create_folder(
self.approot.id, user_input[CONF_FOLDER_NAME]
)
except OneDriveException:
self.logger.debug("Failed to create folder", exc_info=True)
errors["base"] = "folder_creation_error"
else:
if folder.description and folder.description != instance_id:
errors[CONF_FOLDER_NAME] = "folder_already_in_use"
if not errors:
title = (
f"{self.approot.created_by.user.display_name}'s OneDrive"
if self.approot.created_by.user
and self.approot.created_by.user.display_name
else "OneDrive"
)
return self.async_create_entry(
title=title,
data={
**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,
)
return self.async_create_entry(title=title, data=data)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
@@ -92,6 +218,12 @@ class OneDriveConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Reconfigure the entry."""
return await self.async_step_user()
@staticmethod
@callback
def async_get_options_flow(

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,26 @@
"reauth_confirm": {
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The OneDrive integration needs to re-authenticate your account"
},
"folder_name": {
"title": "Pick a folder name",
"description": "This name will be used to create a folder that is specific for this Home Assistant instance. This folder will be created inside `{apps_folder}/{approot}`",
"data": {
"folder_name": "Folder name"
},
"data_description": {
"folder_name": "Name of the folder"
}
},
"reconfigure_folder": {
"title": "Change the folder name",
"description": "Rename the instance specific folder inside `{apps_folder}/{approot}`. This will only rename the folder (and does not select another folder), so make sure the new name is not already in use.",
"data": {
"folder_name": "[%key:component::onedrive::config::step::folder_name::data::folder_name%]"
},
"data_description": {
"folder_name": "[%key:component::onedrive::config::step::folder_name::data_description::folder_name%]"
}
}
},
"abort": {
@@ -23,10 +43,16 @@
"connection_error": "Failed to connect to OneDrive.",
"wrong_drive": "New account does not contain previously configured OneDrive.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"
},
"error": {
"folder_rename_error": "Failed to rename folder",
"folder_creation_error": "Failed to create folder",
"folder_already_in_use": "Folder already used for backups from another Home Assistant instance"
}
},
"options": {

View File

@@ -287,6 +287,9 @@ class OpenAIConversationEntity(
try:
result = await client.chat.completions.create(**model_args)
except openai.RateLimitError as err:
LOGGER.error("Rate limited by OpenAI: %s", err)
raise HomeAssistantError("Rate limited or insufficient funds") from err
except openai.OpenAIError as err:
LOGGER.error("Error talking to OpenAI: %s", err)
raise HomeAssistantError("Error talking to OpenAI") from err

View File

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

View File

@@ -164,7 +164,7 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
)
return None
distance_to_zone = distance(
distance_to_centre = distance(
zone.attributes[ATTR_LATITUDE],
zone.attributes[ATTR_LONGITUDE],
latitude,
@@ -172,8 +172,13 @@ class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]):
)
# it is ensured, that distance can't be None, since zones must have lat/lon coordinates
assert distance_to_zone is not None
return round(distance_to_zone)
assert distance_to_centre is not None
zone_radius: float = zone.attributes["radius"]
if zone_radius > distance_to_centre:
# we've arrived the zone
return 0
return round(distance_to_centre - zone_radius)
def _calc_direction_of_travel(
self,

View File

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

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from enum import StrEnum
from typing import Any
from pyloadapi.api import CannotConnect, InvalidAuth, PyLoadAPI
from pyloadapi import CannotConnect, InvalidAuth, PyLoadAPI
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,11 @@
},
"data_description": {
"host": "The hostname or IP address of the device running your pyLoad instance.",
"port": "pyLoad uses port 8000 by default."
"username": "The username used to access the pyLoad instance.",
"password": "The password associated with the pyLoad account.",
"port": "pyLoad uses port 8000 by default.",
"ssl": "If enabled, the connection to the pyLoad instance will use HTTPS.",
"verify_ssl": "If checked, the SSL certificate will be validated to ensure a secure connection."
}
},
"reconfigure": {
@@ -25,8 +29,12 @@
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "The hostname or IP address of the device running your pyLoad instance.",
"port": "pyLoad uses port 8000 by default."
"host": "[%key:component::pyload::config::step::user::data_description::host%]",
"verify_ssl": "[%key:component::pyload::config::step::user::data_description::verify_ssl%]",
"username": "[%key:component::pyload::config::step::user::data_description::username%]",
"password": "[%key:component::pyload::config::step::user::data_description::password%]",
"port": "[%key:component::pyload::config::step::user::data_description::port%]",
"ssl": "[%key:component::pyload::config::step::user::data_description::ssl%]"
}
},
"reauth_confirm": {
@@ -34,6 +42,10 @@
"data": {
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"username": "[%key:component::pyload::config::step::user::data_description::username%]",
"password": "[%key:component::pyload::config::step::user::data_description::password%]"
}
}
},
@@ -91,10 +103,10 @@
},
"exceptions": {
"setup_request_exception": {
"message": "Unable to connect and retrieve data from pyLoad API, try again later"
"message": "Unable to connect and retrieve data from pyLoad API"
},
"setup_parse_exception": {
"message": "Unable to parse data from pyLoad API, try again later"
"message": "Unable to parse data from pyLoad API"
},
"setup_authentication_exception": {
"message": "Authentication failed for {username}, verify your login credentials"

View File

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

View File

@@ -8,6 +8,7 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import recorder as recorder_helper
from .util import get_instance
@@ -23,27 +24,23 @@ def async_setup(hass: HomeAssistant) -> None:
vol.Required("type"): "recorder/info",
}
)
@callback
def ws_info(
@websocket_api.async_response
async def ws_info(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any]
) -> None:
"""Return status of the recorder."""
if instance := get_instance(hass):
backlog = instance.backlog
migration_in_progress = instance.migration_in_progress
migration_is_live = instance.migration_is_live
recording = instance.recording
# We avoid calling is_alive() as it can block waiting
# for the thread state lock which will block the event loop.
is_running = instance.is_running
max_backlog = instance.max_backlog
else:
backlog = None
migration_in_progress = False
migration_is_live = False
recording = False
is_running = False
max_backlog = None
# Wait for db_connected to ensure the recorder instance is created and the
# migration flags are set.
await hass.data[recorder_helper.DATA_RECORDER].db_connected
instance = get_instance(hass)
backlog = instance.backlog
migration_in_progress = instance.migration_in_progress
migration_is_live = instance.migration_is_live
recording = instance.recording
# We avoid calling is_alive() as it can block waiting
# for the thread state lock which will block the event loop.
is_running = instance.is_running
max_backlog = instance.max_backlog
recorder_info = {
"backlog": backlog,

View File

@@ -50,6 +50,11 @@ STATES_META_SCHEMA_VERSION = 38
LAST_REPORTED_SCHEMA_VERSION = 43
LEGACY_STATES_EVENT_ID_INDEX_SCHEMA_VERSION = 28
LEGACY_STATES_EVENT_FOREIGN_KEYS_FIXED_SCHEMA_VERSION = 43
# https://github.com/home-assistant/core/pull/120779
# fixed the foreign keys in the states table but it did
# not bump the schema version which means only databases
# created with schema 44 and later do not need the rebuild.
INTEGRATION_PLATFORM_COMPILE_STATISTICS = "compile_statistics"
INTEGRATION_PLATFORM_LIST_STATISTIC_IDS = "list_statistic_ids"

View File

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

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