Add an option to hide selected Hyperion effects (#45689)

This commit is contained in:
Dermot Duffy 2021-03-22 07:59:12 -07:00 committed by GitHub
parent 286217f771
commit 781084880b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 275 additions and 22 deletions

View File

@ -26,6 +26,7 @@ from homeassistant.const import (
CONF_TOKEN,
)
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import create_hyperion_client
@ -34,6 +35,8 @@ from . import create_hyperion_client
from .const import (
CONF_AUTH_ID,
CONF_CREATE_TOKEN,
CONF_EFFECT_HIDE_LIST,
CONF_EFFECT_SHOW_LIST,
CONF_PRIORITY,
DEFAULT_ORIGIN,
DEFAULT_PRIORITY,
@ -439,13 +442,44 @@ class HyperionOptionsFlow(OptionsFlow):
"""Initialize a Hyperion options flow."""
self._config_entry = config_entry
def _create_client(self) -> client.HyperionClient:
"""Create and connect a client instance."""
return create_hyperion_client(
self._config_entry.data[CONF_HOST],
self._config_entry.data[CONF_PORT],
token=self._config_entry.data.get(CONF_TOKEN),
)
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> dict[str, Any]:
"""Manage the options."""
effects = {source: source for source in const.KEY_COMPONENTID_EXTERNAL_SOURCES}
async with self._create_client() as hyperion_client:
if not hyperion_client:
return self.async_abort(reason="cannot_connect")
for effect in hyperion_client.effects or []:
if const.KEY_NAME in effect:
effects[effect[const.KEY_NAME]] = effect[const.KEY_NAME]
# If a new effect is added to Hyperion, we always want it to show by default. So
# rather than store a 'show list' in the config entry, we store a 'hide list'.
# However, it's more intuitive to ask the user to select which effects to show,
# so we inverse the meaning prior to storage.
if user_input is not None:
effect_show_list = user_input.pop(CONF_EFFECT_SHOW_LIST)
user_input[CONF_EFFECT_HIDE_LIST] = sorted(
set(effects) - set(effect_show_list)
)
return self.async_create_entry(title="", data=user_input)
default_effect_show_list = list(
set(effects)
- set(self._config_entry.options.get(CONF_EFFECT_HIDE_LIST, []))
)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
@ -456,6 +490,10 @@ class HyperionOptionsFlow(OptionsFlow):
CONF_PRIORITY, DEFAULT_PRIORITY
),
): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)),
vol.Optional(
CONF_EFFECT_SHOW_LIST,
default=default_effect_show_list,
): cv.multi_select(effects),
}
),
)

View File

@ -31,6 +31,8 @@ CONF_INSTANCE_CLIENTS = "INSTANCE_CLIENTS"
CONF_ON_UNLOAD = "ON_UNLOAD"
CONF_PRIORITY = "priority"
CONF_ROOT_CLIENT = "ROOT_CLIENT"
CONF_EFFECT_HIDE_LIST = "effect_hide_list"
CONF_EFFECT_SHOW_LIST = "effect_show_list"
DEFAULT_NAME = "Hyperion"
DEFAULT_ORIGIN = "Home Assistant"

View File

@ -28,6 +28,7 @@ import homeassistant.util.color as color_util
from . import get_hyperion_unique_id, listen_for_instance_updates
from .const import (
CONF_EFFECT_HIDE_LIST,
CONF_INSTANCE_CLIENTS,
CONF_PRIORITY,
DEFAULT_ORIGIN,
@ -217,7 +218,10 @@ class HyperionBaseLight(LightEntity):
def _get_option(self, key: str) -> Any:
"""Get a value from the provided options."""
defaults = {CONF_PRIORITY: DEFAULT_PRIORITY}
defaults = {
CONF_PRIORITY: DEFAULT_PRIORITY,
CONF_EFFECT_HIDE_LIST: [],
}
return self._options.get(key, defaults[key])
async def async_turn_on(self, **kwargs: Any) -> None:
@ -366,11 +370,17 @@ class HyperionBaseLight(LightEntity):
if not self._client.effects:
return
effect_list: list[str] = []
hide_effects = self._get_option(CONF_EFFECT_HIDE_LIST)
for effect in self._client.effects or []:
if const.KEY_NAME in effect:
effect_list.append(effect[const.KEY_NAME])
if effect_list:
self._effect_list = self._static_effect_list + effect_list
effect_name = effect[const.KEY_NAME]
if effect_name not in hide_effects:
effect_list.append(effect_name)
self._effect_list = [
effect for effect in self._static_effect_list if effect not in hide_effects
] + effect_list
self.async_write_ha_state()
@callback

View File

@ -45,7 +45,8 @@
"step": {
"init": {
"data": {
"priority": "Hyperion priority to use for colors and effects"
"priority": "Hyperion priority to use for colors and effects",
"effect_show_list": "Hyperion effects to show"
}
}
}

View File

@ -118,7 +118,9 @@ def create_mock_client() -> Mock:
def add_test_config_entry(
hass: HomeAssistantType, data: dict[str, Any] | None = None
hass: HomeAssistantType,
data: dict[str, Any] | None = None,
options: dict[str, Any] | None = None,
) -> ConfigEntry:
"""Add a test config entry."""
config_entry: MockConfigEntry = MockConfigEntry( # type: ignore[no-untyped-call]
@ -131,7 +133,7 @@ def add_test_config_entry(
},
title=f"Hyperion {TEST_SYSINFO_ID}",
unique_id=TEST_SYSINFO_ID,
options=TEST_CONFIG_ENTRY_OPTIONS,
options=options or TEST_CONFIG_ENTRY_OPTIONS,
)
config_entry.add_to_hass(hass) # type: ignore[no-untyped-call]
return config_entry
@ -141,9 +143,10 @@ async def setup_test_config_entry(
hass: HomeAssistantType,
config_entry: ConfigEntry | None = None,
hyperion_client: Mock | None = None,
options: dict[str, Any] | None = None,
) -> ConfigEntry:
"""Add a test Hyperion entity to hass."""
config_entry = config_entry or add_test_config_entry(hass)
config_entry = config_entry or add_test_config_entry(hass, options=options)
hyperion_client = hyperion_client or create_mock_client()
# pylint: disable=attribute-defined-outside-init

View File

@ -1,8 +1,9 @@
"""Tests for the Hyperion config flow."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock, Mock, patch
from hyperion import const
@ -10,6 +11,8 @@ from homeassistant import data_entry_flow
from homeassistant.components.hyperion.const import (
CONF_AUTH_ID,
CONF_CREATE_TOKEN,
CONF_EFFECT_HIDE_LIST,
CONF_EFFECT_SHOW_LIST,
CONF_PRIORITY,
DOMAIN,
)
@ -309,23 +312,42 @@ async def test_auth_static_token_success(hass: HomeAssistantType) -> None:
}
async def test_auth_static_token_login_fail(hass: HomeAssistantType) -> None:
"""Test correct behavior with a bad static token."""
async def test_auth_static_token_login_connect_fail(hass: HomeAssistantType) -> None:
"""Test correct behavior with a static token that cannot connect."""
result = await _init_flow(hass)
assert result["step_id"] == "user"
client = create_mock_client()
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
# Fail the login call.
client.async_login = AsyncMock(
return_value={"command": "authorize-login", "success": False, "tan": 0}
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
result = await _configure_flow(hass, result, user_input=TEST_HOST_PORT)
client.async_client_connect = AsyncMock(return_value=False)
result = await _configure_flow(
hass, result, user_input={CONF_CREATE_TOKEN: False, CONF_TOKEN: TEST_TOKEN}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "cannot_connect"
async def test_auth_static_token_login_fail(hass: HomeAssistantType) -> None:
"""Test correct behavior with a static token that cannot login."""
result = await _init_flow(hass)
assert result["step_id"] == "user"
client = create_mock_client()
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
result = await _configure_flow(hass, result, user_input=TEST_HOST_PORT)
client.async_login = AsyncMock(
return_value={"command": "authorize-login", "success": False, "tan": 0}
)
result = await _configure_flow(
hass, result, user_input={CONF_CREATE_TOKEN: False, CONF_TOKEN: TEST_TOKEN}
)
@ -377,6 +399,66 @@ async def test_auth_create_token_approval_declined(hass: HomeAssistantType) -> N
assert result["reason"] == "auth_new_token_not_granted_error"
async def test_auth_create_token_approval_declined_task_canceled(
hass: HomeAssistantType,
) -> None:
"""Verify correct behaviour when a token request is declined."""
result = await _init_flow(hass)
client = create_mock_client()
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
result = await _configure_flow(hass, result, user_input=TEST_HOST_PORT)
assert result["step_id"] == "auth"
client.async_request_token = AsyncMock(return_value=TEST_REQUEST_TOKEN_FAIL)
class CanceledAwaitableMock(AsyncMock):
"""A canceled awaitable mock."""
def __await__(self):
raise asyncio.CancelledError
mock_task = CanceledAwaitableMock()
task_coro = None
def create_task(arg):
nonlocal task_coro
task_coro = arg
return mock_task
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
), patch(
"homeassistant.components.hyperion.config_flow.client.generate_random_auth_id",
return_value=TEST_AUTH_ID,
), patch.object(
hass, "async_create_task", side_effect=create_task
):
result = await _configure_flow(
hass, result, user_input={CONF_CREATE_TOKEN: True}
)
assert result["step_id"] == "create_token"
result = await _configure_flow(hass, result)
assert result["step_id"] == "create_token_external"
# Leave the task running, to ensure it is canceled.
mock_task.done = Mock(return_value=False)
mock_task.cancel = Mock()
result = await _configure_flow(hass, result)
# This await will advance to the next step.
await task_coro
# Assert that cancel is called on the task.
assert mock_task.cancel.called
async def test_auth_create_token_when_issued_token_fails(
hass: HomeAssistantType,
) -> None:
@ -468,6 +550,47 @@ async def test_auth_create_token_success(hass: HomeAssistantType) -> None:
}
async def test_auth_create_token_success_but_login_fail(
hass: HomeAssistantType,
) -> None:
"""Verify correct behaviour when a token is successfully created but the login fails."""
result = await _init_flow(hass)
client = create_mock_client()
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
result = await _configure_flow(hass, result, user_input=TEST_HOST_PORT)
assert result["step_id"] == "auth"
client.async_request_token = AsyncMock(return_value=TEST_REQUEST_TOKEN_SUCCESS)
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
), patch(
"homeassistant.components.hyperion.config_flow.client.generate_random_auth_id",
return_value=TEST_AUTH_ID,
):
result = await _configure_flow(
hass, result, user_input={CONF_CREATE_TOKEN: True}
)
assert result["step_id"] == "create_token"
result = await _configure_flow(hass, result)
assert result["step_id"] == "create_token_external"
client.async_login = AsyncMock(
return_value={"command": "authorize-login", "success": False, "tan": 0}
)
# The flow will be automatically advanced by the auth token response.
result = await _configure_flow(hass, result)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "auth_new_token_not_work_error"
async def test_ssdp_success(hass: HomeAssistantType) -> None:
"""Check an SSDP flow."""
@ -599,8 +722,8 @@ async def test_ssdp_abort_duplicates(hass: HomeAssistantType) -> None:
assert result_2["reason"] == "already_in_progress"
async def test_options(hass: HomeAssistantType) -> None:
"""Check an options flow."""
async def test_options_priority(hass: HomeAssistantType) -> None:
"""Check an options flow priority option."""
config_entry = add_test_config_entry(hass)
@ -618,11 +741,12 @@ async def test_options(hass: HomeAssistantType) -> None:
new_priority = 1
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_PRIORITY: new_priority}
result["flow_id"],
user_input={CONF_PRIORITY: new_priority},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {CONF_PRIORITY: new_priority}
assert result["data"][CONF_PRIORITY] == new_priority
# Turn the light on and ensure the new priority is used.
client.async_send_set_color = AsyncMock(return_value=True)
@ -636,6 +760,59 @@ async def test_options(hass: HomeAssistantType) -> None:
assert client.async_send_set_color.call_args[1][CONF_PRIORITY] == new_priority
async def test_options_effect_show_list(hass: HomeAssistantType) -> None:
"""Check an options flow effect show list."""
config_entry = add_test_config_entry(hass)
client = create_mock_client()
client.effects = [
{const.KEY_NAME: "effect1"},
{const.KEY_NAME: "effect2"},
{const.KEY_NAME: "effect3"},
]
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_EFFECT_SHOW_LIST: ["effect1", "effect3"]},
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
# effect1 and effect3 only, so effect2 & external sources are hidden.
assert result["data"][CONF_EFFECT_HIDE_LIST] == sorted(
["effect2"] + const.KEY_COMPONENTID_EXTERNAL_SOURCES
)
async def test_options_effect_hide_list_cannot_connect(hass: HomeAssistantType) -> None:
"""Check an options flow effect hide list with a failed connection."""
config_entry = add_test_config_entry(hass)
client = create_mock_client()
with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
client.async_client_connect = AsyncMock(return_value=False)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "cannot_connect"
async def test_reauth_success(hass: HomeAssistantType) -> None:
"""Check a reauth flow that succeeds."""

View File

@ -6,7 +6,11 @@ from unittest.mock import AsyncMock, Mock, call, patch
from hyperion import const
from homeassistant.components.hyperion import light as hyperion_light
from homeassistant.components.hyperion.const import DEFAULT_ORIGIN, DOMAIN
from homeassistant.components.hyperion.const import (
CONF_EFFECT_HIDE_LIST,
DEFAULT_ORIGIN,
DOMAIN,
)
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
@ -1129,3 +1133,21 @@ async def test_priority_light_has_no_external_sources(hass: HomeAssistantType) -
entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1)
assert entity_state
assert entity_state.attributes["effect_list"] == [hyperion_light.KEY_EFFECT_SOLID]
async def test_light_option_effect_hide_list(hass: HomeAssistantType) -> None:
"""Test the effect_hide_list option."""
client = create_mock_client()
client.effects = [{const.KEY_NAME: "One"}, {const.KEY_NAME: "Two"}]
await setup_test_config_entry(
hass, hyperion_client=client, options={CONF_EFFECT_HIDE_LIST: ["Two", "V4L"]}
)
entity_state = hass.states.get(TEST_ENTITY_ID_1)
assert entity_state.attributes["effect_list"] == [
"Solid",
"BOBLIGHTSERVER",
"GRABBER",
"One",
]