diff --git a/custom_components/yandex_smart_home/cloud.py b/custom_components/yandex_smart_home/cloud.py index 3125d75..4aa9890 100644 --- a/custom_components/yandex_smart_home/cloud.py +++ b/custom_components/yandex_smart_home/cloud.py @@ -154,7 +154,7 @@ def _try_reconnect(self) -> None: return None -async def register_cloud_instance(hass: HomeAssistant) -> CloudInstanceData: +async def register_instance(hass: HomeAssistant) -> CloudInstanceData: """Register a new cloud instance.""" session = async_create_clientsession(hass) @@ -162,3 +162,27 @@ async def register_cloud_instance(hass: HomeAssistant) -> CloudInstanceData: response.raise_for_status() return CloudInstanceData.parse_raw(await response.text()) + + +async def reset_connection_token(hass: HomeAssistant, instance_id: str, token: str) -> CloudInstanceData: + """Reset a cloud instance connection token.""" + session = async_create_clientsession(hass) + + response = await session.post( + f"{BASE_API_URL}/instance/{instance_id}/reset-connection-token", + headers={hdrs.AUTHORIZATION: f"Bearer {token}"}, + ) + response.raise_for_status() + + return CloudInstanceData.parse_raw(await response.text()) + + +async def revoke_oauth_tokens(hass: HomeAssistant, instance_id: str, token: str) -> None: + """Revoke all access and refresh tokens for a cloud instance.""" + session = async_create_clientsession(hass) + + response = await session.post( + f"{BASE_API_URL}/instance/{instance_id}/oauth/revoke-all", + headers={hdrs.AUTHORIZATION: f"Bearer {token}"}, + ) + response.raise_for_status() diff --git a/custom_components/yandex_smart_home/config_flow.py b/custom_components/yandex_smart_home/config_flow.py index a5e560b..fa50d69 100644 --- a/custom_components/yandex_smart_home/config_flow.py +++ b/custom_components/yandex_smart_home/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +from enum import StrEnum import logging from typing import TYPE_CHECKING, cast @@ -24,8 +25,7 @@ from homeassistant.setup import async_setup_component import voluptuous as vol -from . import DOMAIN, FILTER_SCHEMA, SmartHomePlatform -from .cloud import register_cloud_instance +from . import DOMAIN, FILTER_SCHEMA, SmartHomePlatform, cloud from .const import ( CONF_CLOUD_INSTANCE, CONF_CLOUD_INSTANCE_CONNECTION_TOKEN, @@ -53,6 +53,13 @@ PRE_V1_DIRECT_CONFIG_ENTRY_TITLE = "YSH: Direct" # TODO: remove after v1.1 release USER_NONE = "none" + +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" + + CONNECTION_TYPE_SELECTOR = SelectSelector( SelectSelectorConfig( mode=SelectSelectorMode.LIST, @@ -301,7 +308,7 @@ async def async_step_connection_type(self, user_input: ConfigType | None = None) if user_input[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD: try: - instance = await register_cloud_instance(self.hass) + instance = await cloud.register_instance(self.hass) self._data[CONF_CLOUD_INSTANCE] = { CONF_CLOUD_INSTANCE_ID: instance.id, CONF_CLOUD_INSTANCE_PASSWORD: instance.password, @@ -374,6 +381,7 @@ async def async_step_init(self, _: ConfigType | None = None) -> ConfigFlowResult options += ["cloud_credentials", "context_user"] case ConnectionType.DIRECT: options += [f"skill_{self._data[CONF_PLATFORM]}"] + options += ["maintenance"] return self.async_show_menu(step_id="init", menu_options=options) @@ -412,6 +420,63 @@ async def async_step_context_user(self, user_input: ConfigType | None = None) -> ), ) + async def async_step_maintenance(self, user_input: ConfigType | None = None) -> ConfigFlowResult: + """Show maintenance actions.""" + errors: dict[str, str] = {} + description_placeholders = {} + + if user_input is not None: + if user_input.get(MaintenanceAction.REVOKE_OAUTH_TOKENS): + match self._data[CONF_CONNECTION_TYPE]: + case ConnectionType.CLOUD: + try: + await cloud.revoke_oauth_tokens( + self.hass, + self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID], + self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN], + ) + except Exception as e: + errors[MaintenanceAction.REVOKE_OAUTH_TOKENS] = "unknown" + description_placeholders["error"] = str(e) + + case ConnectionType.DIRECT: + errors[MaintenanceAction.REVOKE_OAUTH_TOKENS] = "manual_revoke_oauth_tokens" + + if user_input.get(MaintenanceAction.UNLINK_ALL_PLATFORMS): + self._data[CONF_LINKED_PLATFORMS] = [] + self.hass.config_entries.async_update_entry(self._entry, data=self._data) + + if user_input.get(MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN): + try: + instance = await cloud.reset_connection_token( + self.hass, + self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_ID], + self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN], + ) + self._data[CONF_CLOUD_INSTANCE] = { + CONF_CLOUD_INSTANCE_ID: instance.id, + CONF_CLOUD_INSTANCE_PASSWORD: self._data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_PASSWORD], + CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: instance.connection_token, + } + self.hass.config_entries.async_update_entry(self._entry, data=self._data) + except Exception as e: + errors[MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN] = "unknown" + description_placeholders["error"] = str(e) + + if not errors: + return await self.async_step_done() + + actions = [MaintenanceAction.REVOKE_OAUTH_TOKENS, MaintenanceAction.UNLINK_ALL_PLATFORMS] + if self._data[CONF_CONNECTION_TYPE] == ConnectionType.CLOUD: + actions += [MaintenanceAction.RESET_CLOUD_INSTANCE_CONNECTION_TOKEN] + + return self.async_show_form( + step_id="maintenance", + data_schema=vol.Schema({vol.Optional(action.value): BooleanSelector() for action in actions}), + errors=errors, + description_placeholders=description_placeholders, + ) + async def async_step_done(self, _: ConfigType | None = None) -> ConfigFlowResult: """Finish the flow.""" return self.async_create_entry(data=self._options) diff --git a/custom_components/yandex_smart_home/translations/en.json b/custom_components/yandex_smart_home/translations/en.json index a675ed0..945baab 100644 --- a/custom_components/yandex_smart_home/translations/en.json +++ b/custom_components/yandex_smart_home/translations/en.json @@ -79,9 +79,11 @@ "missing_external_url": "**Ошибка**\n\n**Использование прямого подключение невозможно**: не задан внешний URL-адрес сервера Home Assistant.\n\nДля исправления:\n* Включите расширенный режим в [настройках профиля](https://my.home-assistant.io/redirect/profile/)\n* Задайте URL-адрес сервера в Настройки > Система > [Сеть](https://my.home-assistant.io/redirect/network/)\n* Повторно откройте настройки интеграции" }, "error": { + "unknown": "{error}", "entities_not_selected": "Необходимо выбрать хотя бы один объект", "missing_config_entry": "Не найдено подходящих интеграций", - "already_configured": "Для этого пользователя уже настроена интеграция {entry_title}" + "already_configured": "Для этого пользователя уже настроена интеграция {entry_title}", + "manual_revoke_oauth_tokens": "Автоматическая отвязка навыков не поддерживается. Вы можете отвязать навыки через удаление Токенов обновления в настройках профиля на вкладке Безопасность" }, "step": { "init": { @@ -89,7 +91,8 @@ "expose_settings": "Объекты для передачи в УДЯ", "cloud_credentials": "ID и Пароль (облачное подключение)", "context_user": "Пользователь в журналах", - "skill_yandex": "Параметры навыка" + "skill_yandex": "Параметры навыка", + "maintenance": "Сервисное меню" } }, "expose_settings": { @@ -140,6 +143,20 @@ "id": "С вкладки **Общие сведения** настроек навыка", "token": "Получите по [cсылке](https://oauth.yandex.ru/authorize?response_type=token&client_id=c473ca268cd749d3a8371351a8f2bcbd)\nУбедитесь, что вошли в Яндекс под аккаунтом владельца диалога" } + }, + "maintenance": { + "title": "Сервисное меню", + "description": "**Внимание!** Действия в этом разделе могут привести к невозможности управлять устройствами через УДЯ или к неправильной работе интеграции.", + "data": { + "revoke_oauth_tokens": "Отвязать навыки", + "unlink_all_platforms": "Пометить навыки отвязанными", + "reset_cloud_instance_connection_token": "Обновить токен подключения к Yaha Cloud" + }, + "data_description": { + "revoke_oauth_tokens": "Очищает данные аутентификации для всех привязанных навыков, что приводит к невозможности управления устройствами через УДЯ. После выполнения этой операции необходимо вручную отвязать навык в УДЯ (без удаления устройств) и привязать повторно.", + "unlink_all_platforms": "Отключает отправку уведомлений о состоянии устройств по всем привязанным навыкам. Для возобновления отправки вручную обновите список устройств через УДЯ.", + "reset_cloud_instance_connection_token": "Обновляет служебный токен для подключения к Yaha Cloud, не изменяет ID и пароль." + } } } }, diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 0c1fd68..0f88dd2 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -37,6 +37,7 @@ if TYPE_CHECKING: from homeassistant.auth.models import User + from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from pytest_homeassistant_custom_component.test_util.aiohttp import AiohttpClientMocker @@ -135,6 +136,19 @@ async def _async_forward_to_step_update_filter(hass: HomeAssistant, user: User) return result +async def _async_forward_to_step_maintenance(hass: HomeAssistant, config_entry: ConfigEntry) -> FlowResult: + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "init" + + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"next_step_id": "maintenance"} + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "maintenance" + return result2 + + async def test_config_flow_empty_entities(hass: HomeAssistant, hass_admin_user: User) -> None: result = await hass.config_entries.flow.async_configure( (await _async_forward_to_step_include_entities(hass, hass_admin_user))["flow_id"], @@ -437,7 +451,7 @@ async def test_options_step_init_cloud(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.MENU assert result["step_id"] == "init" - assert result["menu_options"] == ["expose_settings", "cloud_credentials", "context_user"] + assert result["menu_options"] == ["expose_settings", "cloud_credentials", "context_user", "maintenance"] @pytest.mark.parametrize("platform", [SmartHomePlatform.YANDEX]) @@ -450,7 +464,7 @@ async def test_options_step_init_direct(hass: HomeAssistant, platform: SmartHome result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.MENU assert result["step_id"] == "init" - assert result["menu_options"] == ["expose_settings", f"skill_{platform}"] + assert result["menu_options"] == ["expose_settings", f"skill_{platform}", "maintenance"] async def test_options_step_cloud_credentinals(hass: HomeAssistant) -> None: @@ -800,6 +814,118 @@ async def test_options_flow_skill_yandex( assert config_entry.title == "Yandex Smart Home: Direct (Mock User / foobar)" +async def test_options_flow_maintenance_direct(hass: HomeAssistant) -> None: + config_entry = await _async_mock_config_entry( + hass, data={CONF_CONNECTION_TYPE: ConnectionType.DIRECT, CONF_LINKED_PLATFORMS: ["foo"]} + ) + config_entry.add_to_hass(hass) + + result = await _async_forward_to_step_maintenance(hass, config_entry) + assert result["data_schema"] is not None + assert list(result["data_schema"].schema.keys()) == ["revoke_oauth_tokens", "unlink_all_platforms"] + + result_rot = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"revoke_oauth_tokens": True} + ) + assert result_rot["type"] == FlowResultType.FORM + assert result_rot["step_id"] == "maintenance" + assert result_rot["errors"] == {"revoke_oauth_tokens": "manual_revoke_oauth_tokens"} + + 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} + ) + assert result_uap["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert config_entry.data[CONF_LINKED_PLATFORMS] == [] + + +async def test_options_flow_maintenance_cloud(hass: HomeAssistant) -> None: + config_entry = await _async_mock_config_entry( + hass, data={CONF_CONNECTION_TYPE: ConnectionType.CLOUD, CONF_LINKED_PLATFORMS: ["foo"]} + ) + config_entry.add_to_hass(hass) + + result = await _async_forward_to_step_maintenance(hass, config_entry) + assert result["data_schema"] is not None + assert list(result["data_schema"].schema.keys()) == [ + "revoke_oauth_tokens", + "unlink_all_platforms", + "reset_cloud_instance_connection_token", + ] + + 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} + ) + assert result_uap["type"] == FlowResultType.CREATE_ENTRY + await hass.async_block_till_done() + assert config_entry.data[CONF_LINKED_PLATFORMS] == [] + + +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) + + result = await _async_forward_to_step_maintenance(hass, config_entry) + + aioclient_mock.post(f"{cloud.BASE_API_URL}/instance/test/oauth/revoke-all", status=401) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"revoke_oauth_tokens": True} + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "maintenance" + assert result2["errors"] == {"revoke_oauth_tokens": "unknown"} + assert result2["description_placeholders"] == {"error": "401, message='', url='http://example.com'"} + + aioclient_mock.clear_requests() + aioclient_mock.post(f"{cloud.BASE_API_URL}/instance/test/oauth/revoke-all", status=200) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={"revoke_oauth_tokens": True} + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + +async def test_options_flow_maintenance_cloud_reset_token( + 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) + + assert config_entry.data[CONF_CLOUD_INSTANCE][CONF_CLOUD_INSTANCE_CONNECTION_TOKEN] == "foo" + + result = await _async_forward_to_step_maintenance(hass, config_entry) + + aioclient_mock.post(f"{cloud.BASE_API_URL}/instance/test/reset-connection-token", status=401) + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={"reset_cloud_instance_connection_token": True} + ) + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "maintenance" + assert result2["errors"] == {"reset_cloud_instance_connection_token": "unknown"} + assert result2["description_placeholders"] == {"error": "401, message='', url='http://example.com'"} + + aioclient_mock.clear_requests() + aioclient_mock.post( + f"{cloud.BASE_API_URL}/instance/test/reset-connection-token", + status=200, + json={"id": "1234567890", "password": "", "connection_token": "bar"}, + ) + result3 = await hass.config_entries.options.async_configure( + result2["flow_id"], user_input={"reset_cloud_instance_connection_token": True} + ) + assert result3["type"] == FlowResultType.CREATE_ENTRY + + await hass.async_block_till_done() + assert config_entry.data[CONF_CLOUD_INSTANCE] == { + CONF_CLOUD_INSTANCE_ID: "1234567890", + CONF_CLOUD_INSTANCE_PASSWORD: "secret", + CONF_CLOUD_INSTANCE_CONNECTION_TOKEN: "bar", + } + + async def test_config_entry_title_default(hass: HomeAssistant, hass_admin_user: User) -> None: cloud_title = await async_config_entry_title( hass,