From 81f0b9d2ee703e732425342f1b04f0b49c4fec5b Mon Sep 17 00:00:00 2001 From: Artem Sorokin Date: Mon, 2 Dec 2024 15:22:33 +0300 Subject: [PATCH] Add ability to migrate entity filter from yaml (close #566) --- .../yandex_smart_home/__init__.py | 12 +- .../yandex_smart_home/config_flow.py | 36 ++++- .../yandex_smart_home/translations/en.json | 6 +- docs/config/filter.md | 16 ++ tests/test_config_flow.py | 147 +++++++++++++++++- 5 files changed, 205 insertions(+), 12 deletions(-) diff --git a/custom_components/yandex_smart_home/__init__.py b/custom_components/yandex_smart_home/__init__.py index f75f0877..a9196c98 100644 --- a/custom_components/yandex_smart_home/__init__.py +++ b/custom_components/yandex_smart_home/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ID, CONF_PLATFORM, CONF_TOKEN, SERVICE_RELOAD @@ -96,14 +96,20 @@ def get_diagnostics(self) -> ConfigType: return {"yaml_config": async_redact_data(self._yaml_config, [CONF_NOTIFIER])} + def get_entity_filter_from_yaml(self) -> EntityFilter | None: + """Return entity filter from yaml configuration.""" + if entity_filter_config := self._yaml_config.get(CONF_FILTER): + return cast(EntityFilter, FILTER_SCHEMA(entity_filter_config)) + + return None + async def async_setup_entry(self, entry: ConfigEntry) -> bool: """Set up a config entry.""" entity_config = self._yaml_config.get(CONF_ENTITY_CONFIG) entity_filter: EntityFilter | None = None if entry.options.get(CONF_FILTER_SOURCE) == EntityFilterSource.YAML: - if entity_filter_config := self._yaml_config.get(CONF_FILTER): - entity_filter = FILTER_SCHEMA(entity_filter_config) + entity_filter = self.get_entity_filter_from_yaml() else: entity_filter = FILTER_SCHEMA(entry.options.get(CONF_FILTER, {})) diff --git a/custom_components/yandex_smart_home/config_flow.py b/custom_components/yandex_smart_home/config_flow.py index 2e6f35eb..60fbdc39 100644 --- a/custom_components/yandex_smart_home/config_flow.py +++ b/custom_components/yandex_smart_home/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_ENTITIES, CONF_ID, CONF_NAME, CONF_PLATFORM, CONF_TOKEN from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowHandler -from homeassistant.helpers import network, selector +from homeassistant.helpers import entity_registry as er, network, selector from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES, FILTER_SCHEMA, EntityFilter from homeassistant.helpers.selector import ( BooleanSelector, @@ -53,6 +53,8 @@ if TYPE_CHECKING: from homeassistant.config_entries import ConfigFlowContext # noqa: F401 + from . import YandexSmartHome + _LOGGER = logging.getLogger(__name__) @@ -65,6 +67,7 @@ class MaintenanceAction(StrEnum): REVOKE_OAUTH_TOKENS = "revoke_oauth_tokens" UNLINK_ALL_PLATFORMS = "unlink_all_platforms" RESET_CLOUD_INSTANCE_CONNECTION_TOKEN = "reset_cloud_instance_connection_token" + TRANSFER_ENTITY_FILTER_FROM_YAML = "transfer_entity_filter_from_yaml" CONNECTION_TYPE_SELECTOR = SelectSelector( @@ -616,6 +619,9 @@ async def async_step_maintenance(self, user_input: ConfigType | None = None) -> errors: dict[str, str] = {} description_placeholders = {} + component: YandexSmartHome = self.hass.data[DOMAIN] + entity_filter = component.get_entity_filter_from_yaml() + if user_input is not None: if user_input.get(MaintenanceAction.REVOKE_OAUTH_TOKENS): match self._data[CONF_CONNECTION_TYPE]: @@ -654,6 +660,28 @@ async def async_step_maintenance(self, user_input: ConfigType | None = None) -> errors[MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN] = "unknown" description_placeholders["error"] = str(e) + if user_input.get(MaintenanceAction.TRANSFER_ENTITY_FILTER_FROM_YAML): + entity_ids: set[str] = set() + + if entity_filter: + for state in self.hass.states.async_all(): + if entity_filter(state.entity_id): + entity_ids.add(state.entity_id) + + match self._options[CONF_FILTER_SOURCE]: + case EntityFilterSource.CONFIG_ENTRY: + entity_ids.update(self._options[CONF_FILTER][CONF_INCLUDE_ENTITIES]) + self._options[CONF_FILTER] = {CONF_INCLUDE_ENTITIES: sorted(entity_ids)} + + case EntityFilterSource.LABEL: + for entity_id in entity_ids: + registry = er.async_get(self.hass) + if entity := registry.async_get(entity_id): + registry.async_update_entity( + entity.entity_id, + labels=entity.labels | {self._options[CONF_LABEL]}, + ) + if not errors: return await self.async_step_done() @@ -661,6 +689,12 @@ async def async_step_maintenance(self, user_input: ConfigType | None = None) -> if self._data[CONF_CONNECTION_TYPE] in (ConnectionType.CLOUD, ConnectionType.CLOUD_PLUS): actions += [MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN] + if entity_filter and self._options[CONF_FILTER_SOURCE] in [ + EntityFilterSource.CONFIG_ENTRY, + EntityFilterSource.LABEL, + ]: + actions += [MaintenanceAction.TRANSFER_ENTITY_FILTER_FROM_YAML] + return self.async_show_form( step_id="maintenance", data_schema=vol.Schema({vol.Optional(action.value): BooleanSelector() for action in actions}), diff --git a/custom_components/yandex_smart_home/translations/en.json b/custom_components/yandex_smart_home/translations/en.json index fbeb2f4c..8eacd18e 100644 --- a/custom_components/yandex_smart_home/translations/en.json +++ b/custom_components/yandex_smart_home/translations/en.json @@ -250,12 +250,14 @@ "data": { "revoke_oauth_tokens": "Отвязать навыки", "unlink_all_platforms": "Пометить навыки отвязанными", - "reset_cloud_instance_connection_token": "Обновить токен подключения к Yaha Cloud" + "reset_cloud_instance_connection_token": "Обновить токен подключения к Yaha Cloud", + "transfer_entity_filter_from_yaml": "Перенести фильтр объектов из YAML" }, "data_description": { "revoke_oauth_tokens": "Очищает данные аутентификации для всех привязанных навыков, что приводит к невозможности управления устройствами через УДЯ/Марусю. После выполнения этой операции необходимо вручную отвязать навык в УДЯ/Марусе (без удаления устройств) и привязать повторно.", "unlink_all_platforms": "Отключает отправку уведомлений о состоянии устройств по всем привязанным навыкам. Для возобновления отправки вручную обновите список устройств через УДЯ или зайдите в список устройств в приложение Маруся.", - "reset_cloud_instance_connection_token": "Обновляет служебный токен для подключения к Yaha Cloud, не изменяет ID и пароль." + "reset_cloud_instance_connection_token": "Обновляет служебный токен для подключения к Yaha Cloud, не изменяет ID и пароль.", + "transfer_entity_filter_from_yaml": "Переносит список объектов для передачи из YAML конфигурации (параметр `filter`) в настройки интеграции или проставляет этим объектам выбранный ярлык, подробнее в [документации](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#migration-from-yaml)." } } } diff --git a/docs/config/filter.md b/docs/config/filter.md index 93a4ad65..4eb71a97 100644 --- a/docs/config/filter.md +++ b/docs/config/filter.md @@ -70,3 +70,19 @@ filter: include_entity_globs: "*" ``` + +## Миграция с YAML конфигурации { id=migration-from-yaml } + +Если вы выбираете объекты для передачи через YAML конфигурацию и хотите перейти на другой способ выбора - это можно сделать средствами интеграции, а не переносить объекты вручную. + +Для этого: + +1. В [настройках интеграции](./getting-started.md#gui) измените способ выбора объектов для передачи на новый способ: `Через интерфейс` или `Через ярлыки на объектах`. При способе `Через интерфейс` выберите один любой, который уже передаётся в УДЯ. +2. В настройках интеграции откройте `Сервисное меню` и отметьте `Перенести фильтр объектов из YAML` --> `Подтвердить` + +!!! warning "В переносе будут задействованы только объекты, которые попадают под фильтр и **существуют** в Home Assistant на момент выполнения действия." + +Поведение для разных способов выбора объектов: + +* Через интерфейс: объекты, попавшие под фильтр, будут **добавлены** к объектам, которые уже выбраны через интерфейс. +* Через ярлык на объектах: к объектам, попавшим под фильтр, будет **добавлена** та метка, по которой отбираются объекты для передачи в УДЯ. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 16656dc5..3f3c5311 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,13 +1,21 @@ # pyright: reportTypedDictNotRequiredAccess=false from http import HTTPStatus +from unittest.mock import patch +from homeassistant import core from homeassistant.auth.models import User -from homeassistant.components import http +from homeassistant.components import demo, http from homeassistant.config_entries import SOURCE_IGNORE, SOURCE_USER, ConfigEntry, ConfigFlowResult -from homeassistant.const import CONF_ENTITIES, CONF_ID, CONF_NAME, CONF_PLATFORM, CONF_TOKEN +from homeassistant.const import CONF_ENTITIES, CONF_ID, CONF_NAME, CONF_PLATFORM, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entityfilter import ( + CONF_EXCLUDE_ENTITIES, + CONF_INCLUDE_DOMAINS, + CONF_INCLUDE_ENTITIES, + CONF_INCLUDE_ENTITY_GLOBS, +) from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component import pytest @@ -162,6 +170,39 @@ async def _async_forward_to_step_maintenance(hass: HomeAssistant, config_entry: return result2 +async def _async_forward_to_step_transfer_entity_filter_from_yaml( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ConfigFlowResult: + config_entry.add_to_hass(hass) + + await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_FILTER: { + CONF_INCLUDE_ENTITY_GLOBS: ["light.*"], + CONF_INCLUDE_DOMAINS: ["lock"], + CONF_EXCLUDE_ENTITIES: ["lock.front_door", "light.living_room_rgbww_lights"], + } + } + }, + ) + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", [Platform.LIGHT, Platform.LOCK] + ): + await async_setup_component(hass, core.DOMAIN, {}) + await async_setup_component(hass, demo.DOMAIN, {}) + + await hass.async_block_till_done() + + result = await _async_forward_to_step_maintenance(hass, config_entry) + assert result["data_schema"] is not None + assert "transfer_entity_filter_from_yaml" in result["data_schema"].schema.keys() + + return result + + @pytest.mark.parametrize("platform", [SmartHomePlatform.YANDEX, SmartHomePlatform.VK]) async def test_config_flow_empty_entities( hass: HomeAssistant, hass_admin_user: User, platform: SmartHomePlatform @@ -1356,6 +1397,8 @@ async def test_options_flow_maintenance_direct(hass: HomeAssistant) -> None: hass, data={CONF_CONNECTION_TYPE: ConnectionType.DIRECT, CONF_LINKED_PLATFORMS: ["foo"]} ) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() result = await _async_forward_to_step_maintenance(hass, config_entry) assert result["data_schema"] is not None @@ -1383,6 +1426,9 @@ async def test_options_flow_maintenance_cloud(hass: HomeAssistant, connection_ty hass, data={CONF_CONNECTION_TYPE: connection_type, CONF_LINKED_PLATFORMS: ["foo"]} ) config_entry.add_to_hass(hass) + with patch("custom_components.yandex_smart_home.entry_data.ConfigEntryData._async_setup_cloud_connection"): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() result = await _async_forward_to_step_maintenance(hass, config_entry) assert result["data_schema"] is not None @@ -1393,9 +1439,10 @@ async def test_options_flow_maintenance_cloud(hass: HomeAssistant, connection_ty ] assert config_entry.data[CONF_LINKED_PLATFORMS] == ["foo"] - result_uap = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"unlink_all_platforms": True} - ) + with patch("custom_components.yandex_smart_home.entry_data.ConfigEntryData._async_setup_cloud_connection"): + result_uap = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"unlink_all_platforms": True} + ) assert result_uap["type"] == FlowResultType.CREATE_ENTRY await hass.async_block_till_done() assert config_entry.data[CONF_LINKED_PLATFORMS] == [] @@ -1406,6 +1453,8 @@ async def test_options_flow_maintenance_cloud_revoke_tokens( ) -> None: config_entry = await _async_mock_config_entry(hass, data={CONF_CONNECTION_TYPE: ConnectionType.CLOUD}) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() result = await _async_forward_to_step_maintenance(hass, config_entry) @@ -1431,6 +1480,8 @@ async def test_options_flow_maintenance_cloud_reset_token( ) -> None: config_entry = await _async_mock_config_entry(hass, data={CONF_CONNECTION_TYPE: ConnectionType.CLOUD}) config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN] == "foo" @@ -1464,6 +1515,90 @@ async def test_options_flow_maintenance_cloud_reset_token( } +async def test_options_flow_maintenance_transfer_entity_filter_to_config_entry(hass: HomeAssistant) -> None: + config_entry = MockConfigEntry( + domain=DOMAIN, + version=ConfigFlowHandler.VERSION, + data={CONF_CONNECTION_TYPE: ConnectionType.DIRECT, CONF_PLATFORM: SmartHomePlatform.YANDEX}, + options={ + CONF_FILTER_SOURCE: EntityFilterSource.CONFIG_ENTRY, + CONF_FILTER: {CONF_INCLUDE_ENTITIES: ["sensor.foo"]}, + }, + ) + result = await _async_forward_to_step_transfer_entity_filter_from_yaml(hass, config_entry) + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"transfer_entity_filter_from_yaml": True} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + assert config_entry.options == { + "filter": { + "include_entities": [ + "light.bed_light", + "light.ceiling_lights", + "light.entrance_color_white_lights", + "light.kitchen_lights", + "light.office_rgbw_lights", + "lock.kitchen_door", + "lock.openable_lock", + "lock.poorly_installed_door", + "sensor.foo", + ], + }, + "filter_source": "config_entry", + } + + +async def test_options_flow_maintenance_transfer_entity_filter_to_labels( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, +) -> None: + config_entry = MockConfigEntry( + domain=DOMAIN, + version=ConfigFlowHandler.VERSION, + data={CONF_CONNECTION_TYPE: ConnectionType.DIRECT, CONF_PLATFORM: SmartHomePlatform.YANDEX}, + options={ + CONF_FILTER_SOURCE: EntityFilterSource.LABEL, + CONF_LABEL: "foo", + }, + ) + result = await _async_forward_to_step_transfer_entity_filter_from_yaml(hass, config_entry) + + light1_entity = entity_registry.async_get("light.bed_light") + assert light1_entity + assert light1_entity.name is None + assert light1_entity.area_id is None + assert len(light1_entity.labels) == 0 + entity_registry.async_update_entity(light1_entity.entity_id, labels=set(["a", "b", "c"])) + + light2_entity = entity_registry.async_get("light.office_rgbw_lights") + assert light2_entity + assert len(light2_entity.labels) == 0 + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"transfer_entity_filter_from_yaml": True} + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + assert config_entry.options == { + "filter_source": "label", + "label": "foo", + } + + light1_entity = entity_registry.async_get("light.bed_light") + assert light1_entity + assert light1_entity.name is None + assert light1_entity.area_id is None + assert light1_entity.labels == {"a", "b", "c", "foo"} + + light2_entity = entity_registry.async_get("light.office_rgbw_lights") + assert light2_entity + assert light2_entity.labels == {"foo"} + + async def test_config_entry_title_default(hass: HomeAssistant, hass_admin_user: User) -> None: cloud_title = await async_config_entry_title( hass,