Add HEOS options flow for optional authentication (#134105)

* Add heos options flow

* Add options flow tests

* Test error condition during options sign out

* Use credentials when setting up

* Update warning instructions

* Simplify exception logic

* Cover unknown command error condition

* Add test for options

* Correct const import location

* Review feedback

* Update per feedback

* Parameterize tests and remaining feedback

* Correct log level in init

* nitpick feedback
This commit is contained in:
Andrew Sayre 2025-01-02 02:07:34 -06:00 committed by GitHub
parent 877d16273b
commit c9ff575628
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 368 additions and 28 deletions

View File

@ -7,10 +7,23 @@ from dataclasses import dataclass
from datetime import timedelta from datetime import timedelta
import logging 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.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.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import device_registry as dr, entity_registry as er 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) hass.config_entries.async_update_entry(entry, unique_id=DOMAIN)
host = entry.data[CONF_HOST] 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 # Setting all_progress_events=False ensures that we only receive a
# media position update upon start of playback or when media changes # 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: try:
await controller.connect() await controller.connect()
# Auto reconnect only operates if initial connection was successful. # 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() favorites = await controller.get_favorites()
else: else:
_LOGGER.warning( _LOGGER.warning(
( "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
"%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,
) )
inputs = await controller.get_input_sources() inputs = await controller.get_input_sources()
except HeosError as error: except HeosError as error:

View File

@ -1,17 +1,27 @@
"""Config flow to configure Heos.""" """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 urllib.parse import urlparse
from pyheos import Heos, HeosError, HeosOptions from pyheos import CommandFailedError, Heos, HeosError, HeosOptions
import voluptuous as vol import voluptuous as vol
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.config_entries import (
from homeassistant.const import CONF_HOST 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 from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
def format_title(host: str) -> str: def format_title(host: str) -> str:
"""Format the title for config entries.""" """Format the title for config entries."""
@ -36,6 +46,12 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
VERSION = 1 VERSION = 1
@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
"""Create the options flow."""
return HeosOptionsFlowHandler()
async def async_step_ssdp( async def async_step_ssdp(
self, discovery_info: ssdp.SsdpServiceInfo self, discovery_info: ssdp.SsdpServiceInfo
) -> ConfigFlowResult: ) -> ConfigFlowResult:
@ -100,3 +116,80 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}), data_schema=vol.Schema({vol.Required(CONF_HOST, default=host): str}),
errors=errors, 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
),
)

View File

@ -33,10 +33,7 @@ rules:
status: todo status: todo
comment: Actions currently only log and instead should raise exceptions. comment: Actions currently only log and instead should raise exceptions.
config-entry-unloading: done config-entry-unloading: done
docs-configuration-parameters: docs-configuration-parameters: done
status: done
comment: |
The integration doesn't provide any additional configuration parameters.
docs-installation-parameters: done docs-installation-parameters: done
entity-unavailable: done entity-unavailable: done
integration-owner: done integration-owner: done

View File

@ -31,6 +31,28 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" "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": { "services": {
"sign_in": { "sign_in": {
"name": "Sign in", "name": "Sign in",

View File

@ -18,21 +18,60 @@ import pytest
import pytest_asyncio import pytest_asyncio
from homeassistant.components import ssdp from homeassistant.components import ssdp
from homeassistant.components.heos import DOMAIN from homeassistant.components.heos import (
from homeassistant.const import CONF_HOST CONF_PASSWORD,
DOMAIN,
ControllerManager,
GroupManager,
HeosRuntimeData,
SourceManager,
)
from homeassistant.const import CONF_HOST, CONF_USERNAME
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture(name="config_entry") @pytest.fixture(name="config_entry")
def config_entry_fixture(): def config_entry_fixture(heos_runtime_data):
"""Create a mock HEOS config entry.""" """Create a mock HEOS config entry."""
return MockConfigEntry( entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
data={CONF_HOST: "127.0.0.1"}, data={CONF_HOST: "127.0.0.1"},
title="HEOS System (via 127.0.0.1)", title="HEOS System (via 127.0.0.1)",
unique_id=DOMAIN, 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") @pytest.fixture(name="controller")
@ -43,6 +82,7 @@ def controller_fixture(
mock_heos = Mock(Heos) mock_heos = Mock(Heos)
for player in players.values(): for player in players.values():
player.heos = mock_heos player.heos = mock_heos
mock_heos.return_value = mock_heos
mock_heos.dispatcher = dispatcher mock_heos.dispatcher = dispatcher
mock_heos.get_players.return_value = players mock_heos.get_players.return_value = players
mock_heos.players = players mock_heos.players = players
@ -55,11 +95,10 @@ def controller_fixture(
mock_heos.connection_state = const.STATE_CONNECTED mock_heos.connection_state = const.STATE_CONNECTED
mock_heos.get_groups.return_value = group mock_heos.get_groups.return_value = group
mock_heos.create_group.return_value = None mock_heos.create_group.return_value = None
mock = Mock(return_value=mock_heos)
with ( with (
patch("homeassistant.components.heos.Heos", new=mock), patch("homeassistant.components.heos.Heos", new=mock_heos),
patch("homeassistant.components.heos.config_flow.Heos", new=mock), patch("homeassistant.components.heos.config_flow.Heos", new=mock_heos),
): ):
yield mock_heos yield mock_heos

View File

@ -1,11 +1,12 @@
"""Tests for the Heos config flow module.""" """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 import heos, ssdp
from homeassistant.components.heos.const import DOMAIN from homeassistant.components.heos.const import DOMAIN
from homeassistant.config_entries import SOURCE_SSDP, SOURCE_USER 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.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType 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 config_entry.unique_id == DOMAIN
assert result["reason"] == "reconfigure_successful" assert result["reason"] == "reconfigure_successful"
assert result["type"] is FlowResultType.ABORT 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

View File

@ -1,6 +1,7 @@
"""Tests for the init module.""" """Tests for the init module."""
import asyncio import asyncio
from typing import cast
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from pyheos import CommandFailedError, HeosError, const from pyheos import CommandFailedError, HeosError, const
@ -8,11 +9,14 @@ import pytest
from homeassistant.components.heos import ( from homeassistant.components.heos import (
ControllerManager, ControllerManager,
HeosOptions,
HeosRuntimeData, HeosRuntimeData,
async_setup_entry, async_setup_entry,
async_unload_entry, async_unload_entry,
) )
from homeassistant.components.heos.const import DOMAIN 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.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -61,6 +65,32 @@ async def test_async_setup_entry_loads_platforms(
controller.disconnect.assert_not_called() 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( async def test_async_setup_entry_not_signed_in_loads_platforms(
hass: HomeAssistant, hass: HomeAssistant,
config_entry, 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 assert controller.get_input_sources.call_count == 1
controller.disconnect.assert_not_called() controller.disconnect.assert_not_called()
assert ( assert (
"127.0.0.1 is not logged in to a HEOS account and will be unable to retrieve " "The HEOS System is not logged in: Enter credentials in the integration options to access favorites and streaming services"
"HEOS favorites: Use the 'heos.sign_in' service to sign-in to a HEOS account"
in caplog.text in caplog.text
) )