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, CONF_TOKEN,
) )
from homeassistant.core import callback from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from . import create_hyperion_client from . import create_hyperion_client
@ -34,6 +35,8 @@ from . import create_hyperion_client
from .const import ( from .const import (
CONF_AUTH_ID, CONF_AUTH_ID,
CONF_CREATE_TOKEN, CONF_CREATE_TOKEN,
CONF_EFFECT_HIDE_LIST,
CONF_EFFECT_SHOW_LIST,
CONF_PRIORITY, CONF_PRIORITY,
DEFAULT_ORIGIN, DEFAULT_ORIGIN,
DEFAULT_PRIORITY, DEFAULT_PRIORITY,
@ -439,13 +442,44 @@ class HyperionOptionsFlow(OptionsFlow):
"""Initialize a Hyperion options flow.""" """Initialize a Hyperion options flow."""
self._config_entry = config_entry 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( async def async_step_init(
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Manage the options.""" """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: 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) 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( return self.async_show_form(
step_id="init", step_id="init",
data_schema=vol.Schema( data_schema=vol.Schema(
@ -456,6 +490,10 @@ class HyperionOptionsFlow(OptionsFlow):
CONF_PRIORITY, DEFAULT_PRIORITY CONF_PRIORITY, DEFAULT_PRIORITY
), ),
): vol.All(vol.Coerce(int), vol.Range(min=0, max=255)), ): 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_ON_UNLOAD = "ON_UNLOAD"
CONF_PRIORITY = "priority" CONF_PRIORITY = "priority"
CONF_ROOT_CLIENT = "ROOT_CLIENT" CONF_ROOT_CLIENT = "ROOT_CLIENT"
CONF_EFFECT_HIDE_LIST = "effect_hide_list"
CONF_EFFECT_SHOW_LIST = "effect_show_list"
DEFAULT_NAME = "Hyperion" DEFAULT_NAME = "Hyperion"
DEFAULT_ORIGIN = "Home Assistant" 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 . import get_hyperion_unique_id, listen_for_instance_updates
from .const import ( from .const import (
CONF_EFFECT_HIDE_LIST,
CONF_INSTANCE_CLIENTS, CONF_INSTANCE_CLIENTS,
CONF_PRIORITY, CONF_PRIORITY,
DEFAULT_ORIGIN, DEFAULT_ORIGIN,
@ -217,7 +218,10 @@ class HyperionBaseLight(LightEntity):
def _get_option(self, key: str) -> Any: def _get_option(self, key: str) -> Any:
"""Get a value from the provided options.""" """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]) return self._options.get(key, defaults[key])
async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_on(self, **kwargs: Any) -> None:
@ -366,11 +370,17 @@ class HyperionBaseLight(LightEntity):
if not self._client.effects: if not self._client.effects:
return return
effect_list: list[str] = [] effect_list: list[str] = []
hide_effects = self._get_option(CONF_EFFECT_HIDE_LIST)
for effect in self._client.effects or []: for effect in self._client.effects or []:
if const.KEY_NAME in effect: if const.KEY_NAME in effect:
effect_list.append(effect[const.KEY_NAME]) effect_name = effect[const.KEY_NAME]
if effect_list: if effect_name not in hide_effects:
self._effect_list = self._static_effect_list + effect_list 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() self.async_write_ha_state()
@callback @callback

View File

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

View File

@ -1,8 +1,9 @@
"""Tests for the Hyperion config flow.""" """Tests for the Hyperion config flow."""
from __future__ import annotations from __future__ import annotations
import asyncio
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, Mock, patch
from hyperion import const from hyperion import const
@ -10,6 +11,8 @@ from homeassistant import data_entry_flow
from homeassistant.components.hyperion.const import ( from homeassistant.components.hyperion.const import (
CONF_AUTH_ID, CONF_AUTH_ID,
CONF_CREATE_TOKEN, CONF_CREATE_TOKEN,
CONF_EFFECT_HIDE_LIST,
CONF_EFFECT_SHOW_LIST,
CONF_PRIORITY, CONF_PRIORITY,
DOMAIN, 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: async def test_auth_static_token_login_connect_fail(hass: HomeAssistantType) -> None:
"""Test correct behavior with a bad static token.""" """Test correct behavior with a static token that cannot connect."""
result = await _init_flow(hass) result = await _init_flow(hass)
assert result["step_id"] == "user" assert result["step_id"] == "user"
client = create_mock_client() client = create_mock_client()
client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP) client.async_is_auth_required = AsyncMock(return_value=TEST_AUTH_REQUIRED_RESP)
# Fail the login call. with patch(
client.async_login = AsyncMock( "homeassistant.components.hyperion.client.HyperionClient", return_value=client
return_value={"command": "authorize-login", "success": False, "tan": 0} ):
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( with patch(
"homeassistant.components.hyperion.client.HyperionClient", return_value=client "homeassistant.components.hyperion.client.HyperionClient", return_value=client
): ):
result = await _configure_flow(hass, result, user_input=TEST_HOST_PORT) 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( result = await _configure_flow(
hass, result, user_input={CONF_CREATE_TOKEN: False, CONF_TOKEN: TEST_TOKEN} 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" 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( async def test_auth_create_token_when_issued_token_fails(
hass: HomeAssistantType, hass: HomeAssistantType,
) -> None: ) -> 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: async def test_ssdp_success(hass: HomeAssistantType) -> None:
"""Check an SSDP flow.""" """Check an SSDP flow."""
@ -599,8 +722,8 @@ async def test_ssdp_abort_duplicates(hass: HomeAssistantType) -> None:
assert result_2["reason"] == "already_in_progress" assert result_2["reason"] == "already_in_progress"
async def test_options(hass: HomeAssistantType) -> None: async def test_options_priority(hass: HomeAssistantType) -> None:
"""Check an options flow.""" """Check an options flow priority option."""
config_entry = add_test_config_entry(hass) config_entry = add_test_config_entry(hass)
@ -618,11 +741,12 @@ async def test_options(hass: HomeAssistantType) -> None:
new_priority = 1 new_priority = 1
result = await hass.config_entries.options.async_configure( 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() await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY 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. # Turn the light on and ensure the new priority is used.
client.async_send_set_color = AsyncMock(return_value=True) 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 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: async def test_reauth_success(hass: HomeAssistantType) -> None:
"""Check a reauth flow that succeeds.""" """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 hyperion import const
from homeassistant.components.hyperion import light as hyperion_light 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 ( from homeassistant.components.light import (
ATTR_BRIGHTNESS, ATTR_BRIGHTNESS,
ATTR_EFFECT, 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) entity_state = hass.states.get(TEST_PRIORITY_LIGHT_ENTITY_ID_1)
assert entity_state assert entity_state
assert entity_state.attributes["effect_list"] == [hyperion_light.KEY_EFFECT_SOLID] 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",
]