diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index a7acd53739f..acec35a4be7 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -7,10 +7,23 @@ from dataclasses import dataclass from datetime import timedelta import logging -from pyheos import Heos, HeosError, HeosOptions, HeosPlayer, const as heos_const +from pyheos import ( + Credentials, + Heos, + HeosError, + HeosOptions, + HeosPlayer, + const as heos_const, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STOP, + Platform, +) from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -56,9 +69,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool hass.config_entries.async_update_entry(entry, unique_id=DOMAIN) host = entry.data[CONF_HOST] + credentials: Credentials | None = None + if entry.options: + credentials = Credentials( + entry.options[CONF_USERNAME], entry.options[CONF_PASSWORD] + ) + # Setting all_progress_events=False ensures that we only receive a # media position update upon start of playback or when media changes - controller = Heos(HeosOptions(host, all_progress_events=False, auto_reconnect=True)) + controller = Heos( + HeosOptions( + host, + all_progress_events=False, + auto_reconnect=True, + credentials=credentials, + ) + ) try: await controller.connect() # Auto reconnect only operates if initial connection was successful. @@ -83,12 +109,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: HeosConfigEntry) -> bool favorites = await controller.get_favorites() else: _LOGGER.warning( - ( - "%s is not logged in to a HEOS account and will be unable to" - " retrieve HEOS favorites: Use the 'heos.sign_in' service to" - " sign-in to a HEOS account" - ), - host, + "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" ) inputs = await controller.get_input_sources() except HeosError as error: diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 830d708effd..8f985f0823f 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -1,17 +1,27 @@ """Config flow to configure Heos.""" -from typing import TYPE_CHECKING, Any +import logging +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlparse -from pyheos import Heos, HeosError, HeosOptions +from pyheos import CommandFailedError, Heos, HeosError, HeosOptions import voluptuous as vol from homeassistant.components import ssdp -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.const import CONF_HOST +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers import selector from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + def format_title(host: str) -> str: """Format the title for config entries.""" @@ -36,6 +46,12 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow: + """Create the options flow.""" + return HeosOptionsFlowHandler() + async def async_step_ssdp( self, discovery_info: ssdp.SsdpServiceInfo ) -> ConfigFlowResult: @@ -100,3 +116,80 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), errors=errors, ) + + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USERNAME): selector.TextSelector(), + vol.Optional(CONF_PASSWORD): selector.TextSelector( + selector.TextSelectorConfig(type=selector.TextSelectorType.PASSWORD) + ), + } +) + + +class HeosOptionsFlowHandler(OptionsFlow): + """Define HEOS options flow.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + errors: dict[str, str] = {} + if user_input is not None: + authentication = CONF_USERNAME in user_input or CONF_PASSWORD in user_input + if authentication and CONF_USERNAME not in user_input: + errors[CONF_USERNAME] = "username_missing" + if authentication and CONF_PASSWORD not in user_input: + errors[CONF_PASSWORD] = "password_missing" + + if not errors: + heos = cast( + Heos, self.config_entry.runtime_data.controller_manager.controller + ) + + if user_input: + # Attempt to login + try: + await heos.sign_in( + user_input[CONF_USERNAME], user_input[CONF_PASSWORD] + ) + except CommandFailedError as err: + if err.error_id in (6, 8, 10): # Auth-specific errors + errors["base"] = "invalid_auth" + _LOGGER.warning( + "Failed to sign-in to HEOS Account: %s", err + ) + else: + errors["base"] = "unknown" + _LOGGER.exception( + "Unexpected error occurred during sign-in" + ) + except HeosError: + errors["base"] = "unknown" + _LOGGER.exception("Unexpected error occurred during sign-in") + else: + _LOGGER.debug( + "Successfully signed-in to HEOS Account: %s", + heos.signed_in_username, + ) + else: + # Log out + try: + await heos.sign_out() + except HeosError: + errors["base"] = "unknown" + _LOGGER.exception("Unexpected error occurred during sign-out") + else: + _LOGGER.debug("Successfully signed-out of HEOS Account") + + if not errors: + return self.async_create_entry(data=user_input) + + return self.async_show_form( + errors=errors, + step_id="init", + data_schema=self.add_suggested_values_to_schema( + OPTIONS_SCHEMA, user_input or self.config_entry.options + ), + ) diff --git a/homeassistant/components/heos/quality_scale.yaml b/homeassistant/components/heos/quality_scale.yaml index 39c25486e52..7ffac95218e 100644 --- a/homeassistant/components/heos/quality_scale.yaml +++ b/homeassistant/components/heos/quality_scale.yaml @@ -33,10 +33,7 @@ rules: status: todo comment: Actions currently only log and instead should raise exceptions. config-entry-unloading: done - docs-configuration-parameters: - status: done - comment: | - The integration doesn't provide any additional configuration parameters. + docs-configuration-parameters: done docs-installation-parameters: done entity-unavailable: done integration-owner: done diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json index fe4fc63b449..f048e2c4c36 100644 --- a/homeassistant/components/heos/strings.json +++ b/homeassistant/components/heos/strings.json @@ -31,6 +31,28 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, + "options": { + "step": { + "init": { + "title": "HEOS Options", + "description": "You can sign-in to your HEOS Account to access favorites, streaming services, and other features. Clearing the credentials will sign-out of your account.", + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "username": "The username or email address of your HEOS Account.", + "password": "The password to your HEOS Account." + } + } + }, + "error": { + "username_missing": "Username is missing", + "password_missing": "Password is missing", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, "services": { "sign_in": { "name": "Sign in", diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 7b40ff5f749..6451f5bc69e 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -18,21 +18,60 @@ import pytest import pytest_asyncio from homeassistant.components import ssdp -from homeassistant.components.heos import DOMAIN -from homeassistant.const import CONF_HOST +from homeassistant.components.heos import ( + CONF_PASSWORD, + DOMAIN, + ControllerManager, + GroupManager, + HeosRuntimeData, + SourceManager, +) +from homeassistant.const import CONF_HOST, CONF_USERNAME from tests.common import MockConfigEntry @pytest.fixture(name="config_entry") -def config_entry_fixture(): +def config_entry_fixture(heos_runtime_data): """Create a mock HEOS config entry.""" - return MockConfigEntry( + entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, title="HEOS System (via 127.0.0.1)", unique_id=DOMAIN, ) + entry.runtime_data = heos_runtime_data + return entry + + +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture(heos_runtime_data): + """Create a mock HEOS config entry with options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "127.0.0.1"}, + title="HEOS System (via 127.0.0.1)", + options={CONF_USERNAME: "user", CONF_PASSWORD: "pass"}, + unique_id=DOMAIN, + ) + entry.runtime_data = heos_runtime_data + return entry + + +@pytest.fixture(name="heos_runtime_data") +def heos_runtime_data_fixture(controller_manager, players): + """Create a mock HeosRuntimeData fixture.""" + return HeosRuntimeData( + controller_manager, Mock(GroupManager), Mock(SourceManager), players + ) + + +@pytest.fixture(name="controller_manager") +def controller_manager_fixture(controller): + """Create a mock controller manager fixture.""" + mock_controller_manager = Mock(ControllerManager) + mock_controller_manager.controller = controller + return mock_controller_manager @pytest.fixture(name="controller") @@ -43,6 +82,7 @@ def controller_fixture( mock_heos = Mock(Heos) for player in players.values(): player.heos = mock_heos + mock_heos.return_value = mock_heos mock_heos.dispatcher = dispatcher mock_heos.get_players.return_value = players mock_heos.players = players @@ -55,11 +95,10 @@ def controller_fixture( mock_heos.connection_state = const.STATE_CONNECTED mock_heos.get_groups.return_value = group mock_heos.create_group.return_value = None - mock = Mock(return_value=mock_heos) with ( - patch("homeassistant.components.heos.Heos", new=mock), - patch("homeassistant.components.heos.config_flow.Heos", new=mock), + patch("homeassistant.components.heos.Heos", new=mock_heos), + patch("homeassistant.components.heos.config_flow.Heos", new=mock_heos), ): yield mock_heos diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 38382a81794..ea4cb5a580c 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,11 +1,12 @@ """Tests for the Heos config flow module.""" -from pyheos import HeosError +from pyheos import CommandFailedError, HeosError +import pytest from homeassistant.components import heos, ssdp from homeassistant.components.heos.const import DOMAIN from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -190,3 +191,141 @@ async def test_reconfigure_cannot_connect_recovers( assert config_entry.unique_id == DOMAIN assert result["reason"] == "reconfigure_successful" assert result["type"] is FlowResultType.ABORT + + +@pytest.mark.parametrize( + ("error", "expected_error_key"), + [ + ( + CommandFailedError("sign_in", "Invalid credentials", 6), + "invalid_auth", + ), + ( + CommandFailedError("sign_in", "User not logged in", 8), + "invalid_auth", + ), + (CommandFailedError("sign_in", "user not found", 10), "invalid_auth"), + (CommandFailedError("sign_in", "System error", 12), "unknown"), + (HeosError(), "unknown"), + ], +) +async def test_options_flow_signs_in( + hass: HomeAssistant, + config_entry, + controller, + error: HeosError, + expected_error_key: str, +) -> None: + """Test options flow signs-in with entered credentials.""" + config_entry.add_to_hass(hass) + + # Start the options flow. Entry has not current options. + assert CONF_USERNAME not in config_entry.options + assert CONF_PASSWORD not in config_entry.options + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["step_id"] == "init" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Invalid credentials, system error, or unexpected error. + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + controller.sign_in.side_effect = error + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["step_id"] == "init" + assert result["errors"] == {"base": expected_error_key} + assert result["type"] is FlowResultType.FORM + + # Valid credentials signs-in and creates entry + controller.sign_in.reset_mock() + controller.sign_in.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_options_flow_signs_out( + hass: HomeAssistant, config_entry, controller +) -> None: + """Test options flow signs-out when credentials cleared.""" + config_entry.add_to_hass(hass) + + # Start the options flow. Entry has not current options. + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["step_id"] == "init" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Fail to sign-out, show error + user_input = {} + controller.sign_out.side_effect = HeosError() + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 1 + assert result["step_id"] == "init" + assert result["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + + # Clear credentials + controller.sign_out.reset_mock() + controller.sign_out.side_effect = None + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 0 + assert controller.sign_out.call_count == 1 + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("user_input", "expected_errors"), + [ + ({CONF_USERNAME: "user"}, {CONF_PASSWORD: "password_missing"}), + ({CONF_PASSWORD: "pass"}, {CONF_USERNAME: "username_missing"}), + ], +) +async def test_options_flow_missing_one_param_recovers( + hass: HomeAssistant, + config_entry, + controller, + user_input: dict[str, str], + expected_errors: dict[str, str], +) -> None: + """Test options flow signs-in after recovering from only username or password being entered.""" + config_entry.add_to_hass(hass) + + # Start the options flow. Entry has not current options. + assert CONF_USERNAME not in config_entry.options + assert CONF_PASSWORD not in config_entry.options + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["step_id"] == "init" + assert result["errors"] == {} + assert result["type"] is FlowResultType.FORM + + # Enter only username or password + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert result["step_id"] == "init" + assert result["errors"] == expected_errors + assert result["type"] is FlowResultType.FORM + + # Enter valid credentials + user_input = {CONF_USERNAME: "user", CONF_PASSWORD: "pass"} + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input + ) + assert controller.sign_in.call_count == 1 + assert controller.sign_out.call_count == 0 + assert result["data"] == user_input + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py index 8d2e3b68a22..31ddb80edb7 100644 --- a/tests/components/heos/test_init.py +++ b/tests/components/heos/test_init.py @@ -1,6 +1,7 @@ """Tests for the init module.""" import asyncio +from typing import cast from unittest.mock import Mock, patch from pyheos import CommandFailedError, HeosError, const @@ -8,11 +9,14 @@ import pytest from homeassistant.components.heos import ( ControllerManager, + HeosOptions, HeosRuntimeData, async_setup_entry, async_unload_entry, ) from homeassistant.components.heos.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.setup import async_setup_component @@ -61,6 +65,32 @@ async def test_async_setup_entry_loads_platforms( controller.disconnect.assert_not_called() +async def test_async_setup_entry_with_options_loads_platforms( + hass: HomeAssistant, + config_entry_options, + config, + controller, + input_sources, + favorites, +) -> None: + """Test load connects to heos with options, retrieves players, and loads platforms.""" + config_entry_options.add_to_hass(hass) + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + # Assert options passed and methods called + assert config_entry_options.state is ConfigEntryState.LOADED + options = cast(HeosOptions, controller.call_args[0][0]) + assert options.host == config_entry_options.data[CONF_HOST] + assert options.credentials.username == config_entry_options.options[CONF_USERNAME] + assert options.credentials.password == config_entry_options.options[CONF_PASSWORD] + assert controller.connect.call_count == 1 + assert controller.get_players.call_count == 1 + assert controller.get_favorites.call_count == 1 + assert controller.get_input_sources.call_count == 1 + controller.disconnect.assert_not_called() + + async def test_async_setup_entry_not_signed_in_loads_platforms( hass: HomeAssistant, config_entry, @@ -85,8 +115,7 @@ async def test_async_setup_entry_not_signed_in_loads_platforms( assert controller.get_input_sources.call_count == 1 controller.disconnect.assert_not_called() assert ( - "127.0.0.1 is not logged in to a HEOS account and will be unable to retrieve " - "HEOS favorites: Use the 'heos.sign_in' service to sign-in to a HEOS account" + "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services" in caplog.text )