diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index bea8971cfa6..2b8d0ee8d8f 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -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), } ), ) diff --git a/homeassistant/components/hyperion/const.py b/homeassistant/components/hyperion/const.py index 64c2f20052b..994ef580c91 100644 --- a/homeassistant/components/hyperion/const.py +++ b/homeassistant/components/hyperion/const.py @@ -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" diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index d322362e959..36c3d836bf3 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -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,12 +370,18 @@ 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 - self.async_write_ha_state() + 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 def _update_full_state(self) -> None: diff --git a/homeassistant/components/hyperion/strings.json b/homeassistant/components/hyperion/strings.json index ca7ed238f0b..54beb7704c9 100644 --- a/homeassistant/components/hyperion/strings.json +++ b/homeassistant/components/hyperion/strings.json @@ -45,9 +45,10 @@ "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" } } } } -} \ No newline at end of file +} diff --git a/tests/components/hyperion/__init__.py b/tests/components/hyperion/__init__.py index e811a4dde32..d0653f88b83 100644 --- a/tests/components/hyperion/__init__.py +++ b/tests/components/hyperion/__init__.py @@ -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 diff --git a/tests/components/hyperion/test_config_flow.py b/tests/components/hyperion/test_config_flow.py index 15bca12b03f..7cf0556eddf 100644 --- a/tests/components/hyperion/test_config_flow.py +++ b/tests/components/hyperion/test_config_flow.py @@ -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.""" diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index e83f5059939..bb8fe8d0814 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -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", + ]