Skip to content

Commit

Permalink
Add ability to migrate entity filter from yaml (close #566)
Browse files Browse the repository at this point in the history
  • Loading branch information
dext0r committed Dec 2, 2024
1 parent ed53070 commit 43c0f0d
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 9 deletions.
12 changes: 9 additions & 3 deletions custom_components/yandex_smart_home/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, {}))

Expand Down
36 changes: 35 additions & 1 deletion custom_components/yandex_smart_home/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,6 +53,8 @@
if TYPE_CHECKING:
from homeassistant.config_entries import ConfigFlowContext # noqa: F401

from . import YandexSmartHome


_LOGGER = logging.getLogger(__name__)

Expand All @@ -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(
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -654,13 +660,41 @@ 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()

actions = [MaintenanceAction.REVOKE_OAUTH_TOKENS, MaintenanceAction.UNLINK_ALL_PLATFORMS]
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}),
Expand Down
6 changes: 4 additions & 2 deletions custom_components/yandex_smart_home/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)."
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions docs/config/filter.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,19 @@
filter:
include_entity_globs: "*"
```

## Миграция с YAML конфигурации { id=migration-from-yaml }

Если вы выбираете объекты для передачи через YAML конфигурацию и хотите перейти на другой способ выбора - это можно сделать средствами интеграции, а не переносить объекты вручную.

Для этого:

1. В [настройках интеграции](./getting-started.md#gui) измените способ выбора объектов для передачи на новый способ: `Через интерфейс` или `Через ярлыки на объектах`. При способе `Через интерфейс` выберите один любой, который уже передаётся в УДЯ.
2. В настройках интеграции откройте `Сервисное меню` и отметьте `Перенести фильтр объектов из YAML` --> `Подтвердить`

!!! warning "В переносе будут задействованы только объекты, которые попадают под фильтр и **существуют** в Home Assistant на момент выполнения действия."

Поведение для разных способов выбора объектов:

* Через интерфейс: объекты, попавшие под фильтр, будут **добавлены** к объектам, которые уже выбраны через интерфейс.
* Через ярлык на объектах: к объектам, попавшим под фильтр, будет **добавлена** та метка, по которой отбираются объекты для передачи в УДЯ.
141 changes: 138 additions & 3 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1383,6 +1426,8 @@ 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)
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
Expand All @@ -1400,12 +1445,16 @@ async def test_options_flow_maintenance_cloud(hass: HomeAssistant, connection_ty
await hass.async_block_till_done()
assert config_entry.data[CONF_LINKED_PLATFORMS] == []

await hass.config_entries.async_unload(config_entry.entry_id)


async def test_options_flow_maintenance_cloud_revoke_tokens(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> 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)

Expand All @@ -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"

Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 43c0f0d

Please sign in to comment.