Skip to content

Commit

Permalink
Add maintenance menu (close #511)
Browse files Browse the repository at this point in the history
  • Loading branch information
dext0r committed May 8, 2024
1 parent dbd8846 commit 50f3e6f
Show file tree
Hide file tree
Showing 4 changed files with 240 additions and 8 deletions.
26 changes: 25 additions & 1 deletion custom_components/yandex_smart_home/cloud.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,35 @@ 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)

response = await session.post(f"{BASE_API_URL}/instance/register")
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()
71 changes: 68 additions & 3 deletions custom_components/yandex_smart_home/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

from enum import StrEnum
import logging
from typing import TYPE_CHECKING, cast

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

Expand Down Expand Up @@ -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)
Expand Down
21 changes: 19 additions & 2 deletions custom_components/yandex_smart_home/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,20 @@
"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": {
"menu_options": {
"expose_settings": "Объекты для передачи в УДЯ",
"cloud_credentials": "ID и Пароль (облачное подключение)",
"context_user": "Пользователь в журналах",
"skill_yandex": "Параметры навыка"
"skill_yandex": "Параметры навыка",
"maintenance": "Сервисное меню"
}
},
"expose_settings": {
Expand Down Expand Up @@ -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 и пароль."
}
}
}
},
Expand Down
130 changes: 128 additions & 2 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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])
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit 50f3e6f

Please sign in to comment.