Skip to content

Commit

Permalink
Add repair for unexposed entity interaction (close #579)
Browse files Browse the repository at this point in the history
  • Loading branch information
dext0r committed Jan 16, 2025
1 parent 6b330a1 commit 5ceb6bd
Show file tree
Hide file tree
Showing 14 changed files with 601 additions and 21 deletions.
3 changes: 3 additions & 0 deletions custom_components/yandex_smart_home/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .entry_data import ConfigEntryData
from .helpers import SmartHomePlatform
from .http import async_register_http
from .repairs import delete_unexposed_entity_found_issues

if TYPE_CHECKING:
from .cloud_stream import CloudStreamManager
Expand Down Expand Up @@ -123,11 +124,13 @@ async def async_setup_entry(self, entry: ConfigEntry) -> bool:

self._entry_datas[entry.entry_id] = await data.async_setup()
entry.async_on_unload(entry.add_update_listener(_async_entry_update_listener))
delete_unexposed_entity_found_issues(self._hass)

return True

async def async_unload_entry(self, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
delete_unexposed_entity_found_issues(self._hass)
data = self.get_entry_data(entry)
await data.async_unload()
return True
Expand Down
2 changes: 2 additions & 0 deletions custom_components/yandex_smart_home/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
CONF_FILTER_SOURCE = "filter_source"
CONF_ENTRY_ALIASES = "entry_aliases"
CONF_LABEL = "label"
CONF_ADD_LABEL = "add_label"
CONF_LINKED_PLATFORMS = "linked_platforms"
CONF_TURN_ON = "turn_on"
CONF_TURN_OFF = "turn_off"
Expand Down Expand Up @@ -62,6 +63,7 @@
ISSUE_ID_MISSING_INTEGRATION = "missing_integration"
ISSUE_ID_MISSING_SKILL_DATA = "missing_skill_data"
ISSUE_ID_RECONNECTING_TOO_FAST = "reconnecting_too_fast"
ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND = "unexposed_entity_found_"

# Legacy
CONF_DEVICES_DISCOVERED = "devices_discovered"
Expand Down
11 changes: 5 additions & 6 deletions custom_components/yandex_smart_home/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -515,12 +515,11 @@ async def async_get_device_states(
states: list[DeviceState] = []

for device_id in device_ids:
device = Device(hass, entry_data, device_id, hass.states.get(device_id))
if not device.should_expose:
_LOGGER.warning(
f"State requested for unexposed entity {device.id}. Please either expose the entity via "
f"filters in component configuration or delete the device from Yandex."
)
state = hass.states.get(device_id)
device = Device(hass, entry_data, device_id, state)

if state and not device.should_expose:
entry_data.mark_entity_unexposed(state.entity_id)

states.append(device.query())

Expand Down
39 changes: 38 additions & 1 deletion custom_components/yandex_smart_home/entry_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
CONF_TOKEN,
EVENT_HOMEASSISTANT_STARTED,
EVENT_HOMEASSISTANT_STOP,
STATE_UNKNOWN,
)
from homeassistant.core import CoreState, HomeAssistant
from homeassistant.core import CoreState, HomeAssistant, State
from homeassistant.helpers import entity_registry as er, issue_registry as ir
from homeassistant.helpers.entityfilter import EntityFilter
from homeassistant.helpers.template import Template
Expand Down Expand Up @@ -54,6 +55,7 @@
ISSUE_ID_DEPRECATED_YAML_NOTIFIER,
ISSUE_ID_DEPRECATED_YAML_SEVERAL_NOTIFIERS,
ISSUE_ID_MISSING_SKILL_DATA,
ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND,
ConnectionType,
EntityFilterSource,
EntityId,
Expand Down Expand Up @@ -95,6 +97,7 @@ def __init__(
"""Initialize."""
self.entry = entry
self.entity_config: ConfigType = entity_config or {}
self.unexposed_entities: set[str] = set()
self._yaml_config: ConfigType = yaml_config or {}

self.component_version = "unknown"
Expand Down Expand Up @@ -288,6 +291,40 @@ def unlink_platform(self, platform: SmartHomePlatform) -> None:

self._hass.config_entries.async_update_entry(self.entry, data=data)

def mark_entity_unexposed(self, entity_id: str) -> None:
"""Create an issue for unexposed entity."""
_LOGGER.warning(
f"Device for {entity_id} exists in Yandex, but entity {entity_id} not exposed via integration settings. "
f"Please either expose the entity or delete the device from Yandex."
)

self.unexposed_entities.add(entity_id)
issue_id = ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + self.entry.options[CONF_FILTER_SOURCE]

formatted_entities: list[str] = []
for entity_id in sorted(self.unexposed_entities):
if self.entry.options[CONF_FILTER_SOURCE] == EntityFilterSource.YAML:
formatted_entities.append(f"* `- {entity_id}`")
else:
state = self._hass.states.get(entity_id) or State(entity_id, STATE_UNKNOWN)
formatted_entities.append(f"* `{state.entity_id}` ({state.name})")

ir.async_create_issue(
self._hass,
DOMAIN,
issue_id,
is_fixable=self.entry.options[CONF_FILTER_SOURCE] != EntityFilterSource.YAML,
is_persistent=True,
severity=ir.IssueSeverity.WARNING,
data={"entry_id": self.entry.entry_id},
translation_key=issue_id,
translation_placeholders={
"entry_title": self.entry.title,
"entities": "\n".join(formatted_entities),
},
learn_more_url="https://docs.yaha-cloud.ru/v1.0.x/config/filter/",
)

async def _async_setup_notifiers(self, *_: Any) -> None:
"""Set up notifiers."""
if self.is_reporting_states or self.platform == SmartHomePlatform.VK:
Expand Down
6 changes: 5 additions & 1 deletion custom_components/yandex_smart_home/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,11 @@ async def async_devices_action(hass: HomeAssistant, data: RequestData, payload:
results: list[ActionResultDevice] = []

for device_id, actions in [(rd.id, rd.capabilities) for rd in request.payload.devices]:
device = Device(hass, data.entry_data, device_id, hass.states.get(device_id))
state = hass.states.get(device_id)
device = Device(hass, data.entry_data, device_id, state)

if state and not device.should_expose:
data.entry_data.mark_entity_unexposed(state.entity_id)

if device.unavailable:
hass.bus.async_fire(
Expand Down
123 changes: 123 additions & 0 deletions custom_components/yandex_smart_home/repairs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""Repairs for the Yandex Smart Home."""

from typing import TYPE_CHECKING, cast

from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers import entity_registry as er, issue_registry as ir, label_registry as lr
from homeassistant.helpers.entityfilter import CONF_INCLUDE_ENTITIES
from homeassistant.helpers.selector import BooleanSelector
import voluptuous as vol

from . import DOMAIN
from .const import CONF_ADD_LABEL, CONF_FILTER, CONF_LABEL, ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND, EntityFilterSource
from .entry_data import ConfigEntryData

if TYPE_CHECKING:
from . import YandexSmartHome


class EmptyRepairFlow(RepairsFlow):
"""Handler for an issue fixing flow without any side effects."""

async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return self.async_create_entry(data={})


class UnexposedEntityFoundConfigEntryRepairFlow(RepairsFlow):
"""Handler for an "unexposed entity found" issue fixing flow."""

def __init__(self, entry_data: ConfigEntryData) -> None:
"""Initialize the flow."""
self._entry_data = entry_data

async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()

async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> FlowResult:
"""Handle the confirm step of a fix flow."""
if user_input is not None:
if user_input[CONF_INCLUDE_ENTITIES]:
entry = self._entry_data.entry
options = entry.options.copy()
options[CONF_FILTER] = {
CONF_INCLUDE_ENTITIES: sorted(
set(entry.options[CONF_FILTER][CONF_INCLUDE_ENTITIES]) | self._entry_data.unexposed_entities
)
}
self.hass.config_entries.async_update_entry(entry, options=options)

return self.async_create_entry(data={})

return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({vol.Required(CONF_INCLUDE_ENTITIES): BooleanSelector()}),
)


class UnexposedEntityFoundLabelRepairFlow(RepairsFlow):
"""Handler for an "unexposed entity found" issue fixing flow."""

def __init__(self, entry_data: ConfigEntryData) -> None:
"""Initialize the flow."""
self._entry_data = entry_data

async def async_step_init(self, _: dict[str, str] | None = None) -> FlowResult:
"""Handle the first step of a fix flow."""
return await self.async_step_confirm()

async def async_step_confirm(self, user_input: dict[str, str] | None = None) -> FlowResult:
"""Handle the confirm step of a fix flow."""
label = self._entry_data.entry.options[CONF_LABEL]
label_entry = lr.async_get(self.hass).async_get_label(label)

if user_input is not None:
if user_input[CONF_ADD_LABEL]:
registry = er.async_get(self.hass)
for entity_id in self._entry_data.unexposed_entities:
if entity := registry.async_get(entity_id):
registry.async_update_entity(
entity.entity_id,
labels=entity.labels | {label},
)

return self.async_create_entry(data={})

return self.async_show_form(
step_id="confirm",
data_schema=vol.Schema({vol.Required(CONF_ADD_LABEL): BooleanSelector()}),
description_placeholders={CONF_LABEL: label_entry.name if label_entry else label},
)


async def async_create_fix_flow(
hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None
) -> RepairsFlow:
"""Create flow."""
assert data is not None
entry = hass.config_entries.async_get_entry(cast(str, data["entry_id"]))
if not entry or DOMAIN not in hass.data:
return EmptyRepairFlow()

component: YandexSmartHome = hass.data[DOMAIN]
try:
entry_data = component.get_entry_data(entry)
except KeyError:
return EmptyRepairFlow()

if issue_id == ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + EntityFilterSource.CONFIG_ENTRY:
return UnexposedEntityFoundConfigEntryRepairFlow(entry_data)

if issue_id == ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + EntityFilterSource.LABEL:
return UnexposedEntityFoundLabelRepairFlow(entry_data)

raise NotImplementedError


def delete_unexposed_entity_found_issues(hass: HomeAssistant) -> None:
"""Delete repair issues for an unexposed entity."""
for filter_source in EntityFilterSource:
ir.async_delete_issue(hass, DOMAIN, ISSUE_ID_PREFIX_UNEXPOSED_ENTITY_FOUND + filter_source)
30 changes: 30 additions & 0 deletions custom_components/yandex_smart_home/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,36 @@
"reconnecting_too_fast": {
"title": "Частые переподключения",
"description": "Интеграция {entry_title} слишком часто переподключается к облачному серверу. Это приводит к периодической невозможности управлять устройствами из УДЯ.\n\nСкорее всего одновременно запущено несколько Home Assistant, которые были развернуты из одной резервной копии."
},
"unexposed_entity_found_config_entry": {
"title": "Не выбран один или несколько объектов для передачи",
"fix_flow": {
"step": {
"confirm": {
"description": "В УДЯ существуют устройства, объекты которых не выбраны в списке объектов для передачи в настройках интеграции {entry_title}.\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Добавьте затронутые объекты в список для передачи в [настройках](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#config-flow) интеграции или выберите \"Добавить автоматически\" и нажмите \"Подтвердить\" для автоматического добавления\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}",
"data": {
"include_entities": "Добавить автоматически все затронутые объекты в список для передачи"
}
}
}
}
},
"unexposed_entity_found_yaml": {
"title": "Не выбран один или несколько объектов для передачи",
"description": "В УДЯ существуют устройства, объекты которых не попадают под [фильтры](https://docs.yaha-cloud.ru/v1.0.x/config/filter/#yaml) в YAML конфигурации.\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Добавьте затронутые объекты список `include_entities` в параметре `yandex_smart_home.filter` YAML конфигурации и перезагрузите её через Панель разработчика\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}"
},
"unexposed_entity_found_label": {
"title": "Не выбран один или несколько объектов для передачи",
"fix_flow": {
"step": {
"confirm": {
"description": "В УДЯ существуют устройства, объекты которых не содержат ярлык \"{label}\".\n\nЭто может приводить к некорректному отображению состояний устройств в УДЯ и не влияет на управление устройствами.\n\nСпособы решения проблемы:\n1. Вручную добавьте ярлык \"{label}\" на затронутые объекты или выберите \"Добавить автоматически\" и нажмите \"Подтвердить\" для автоматического добавления ярлыка\n\n2. Удалите из УДЯ лишние устройства и перезагрузите интеграцию или Home Assistant\n\nЗатронутые объекты:\n{entities}",
"data": {
"add_label": "Добавить автоматически ярлык \"{label}\" на все затронутые объекты"
}
}
}
}
}
},
"selector": {
Expand Down
4 changes: 2 additions & 2 deletions docs/config/filter.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
По умолчанию в УДЯ не передаются никакие объекты.

Выбрать объекты, которые будут переданы в УДЯ в виде устройств можно несколькими способами (в зависимости от ваших предпочтений): в настройках интеграции, ярлыки на объектах, или YAML конфигурацию.
Выбрать объекты, которые будут переданы в УДЯ в виде устройств можно несколькими способами (в зависимости от ваших предпочтений): в настройках интеграции, ярлыки на объектах, или YAML конфигурацию. Способ выбора устанавливается в настройках интеграции, одновременное использование разных способов невозможно.

!!! danger "Внимание!"
Удаление устройств из УДЯ возможно **только** вручную. Удаляйте устройство из УДЯ только после исключения объекта из списка для передачи.
Для удаления всех устройств - [отвяжите навык (производителя)](../platforms/yandex.md#unlink).

Маруся всегда отображает только те устройства, которые выбраны в списке для передачи.

!!! warning "Недопустимо оставлять в УДЯ устройства, у которых объект не выбран для передачи. Такие устройства будут работать некорректно."
!!! warning "Недопустимо оставлять в УДЯ устройства, у которых объект не выбран для передачи. Такие устройства могут работать некорректно, а при запросе их состояния через УДЯ в Home Assistant будет создана проблема."

## В настройках интеграции { id=config-flow }

Expand Down
4 changes: 2 additions & 2 deletions docs/troubleshoot.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@
* Для прямого подключения: убедитесь, что Home Assistant доступен из интернета
* Для облачного подключения: напишите в [чат](https://t.me/yandex_smart_home) в Telegram указав первые 6 символов вашего ID (можно посмотреть в [настройках интеграции](./config/getting-started.md#gui))

## State requested for unexposed entity { id=unexposed-entity }
## Device for XXX exists in Yandex, but entity XXX not exposed { id=unexposed-entity }

В УДЯ присутствует устройство, но объект по которому оно создано не выбран в [объектах для передачи в УДЯ](./config/filter.md).
В УДЯ присутствует устройство, но объект, по которому оно создано, не выбран в [объектах для передачи в УДЯ](./config/filter.md).

Для исправления: удалите устройство из УДЯ или добавьте [объект](./faq.md#get-entity-id-app) в список устройств для передачи.

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ disable_error_code = [
"func-returns-value",
]

[[tool.mypy.overrides]]
module = "tests.test_repairs"
disallow_any_generics = false

[tool.tox]
env_list = [
"type",
Expand Down
9 changes: 7 additions & 2 deletions tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from homeassistant.helpers.typing import ConfigType
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.yandex_smart_home import DOMAIN
from custom_components.yandex_smart_home import CONF_FILTER_SOURCE, DOMAIN, EntityFilterSource
from custom_components.yandex_smart_home.config_flow import ConfigFlowHandler
from custom_components.yandex_smart_home.entry_data import ConfigEntryData
from custom_components.yandex_smart_home.helpers import STORE_CACHE_ATTRS, CacheStore
Expand All @@ -26,7 +26,12 @@ def __init__(
entity_filter: entityfilter.EntityFilter | None = None,
):
if not entry:
entry = MockConfigEntry(domain=DOMAIN, version=ConfigFlowHandler.VERSION, data={}, options={})
entry = MockConfigEntry(
domain=DOMAIN,
version=ConfigFlowHandler.VERSION,
data={},
options={CONF_FILTER_SOURCE: EntityFilterSource.YAML},
)

super().__init__(hass, entry, yaml_config, entity_config, entity_filter)

Expand Down
Loading

0 comments on commit 5ceb6bd

Please sign in to comment.