Add config flow to Russound RIO integration (#121262)

* Add config flow to Russound RIO

* Ensure Russound RIO connection is handled at entry setup

* Add tests for Russound RIO config flow

* Add yaml configuration import to Russound RIO

* Use runtime_data to store Russound RIO client

* Seperate common import and user config logic for Russound RIO

* Update config flow to use aiorussound

* Add MAC address as unique ID for Russound RIO

* Fix pre-commit for Russound RIO

* Refactor config flow error handling for Russound RIO

* Add config flow import abort message for no primary controller

* Add common strings to Russound RIO

* Use reference strings for Russound RIO issue strings

* Remove commented out test fixture from Russound RIO

* Clean up test fixtures for Russound RIO

* Remove model from entry data in Russound RIO

* Clean up Russound client mock

* Clean up Russound test fixtures

* Remove init tests and clean up Russound config flow cases
This commit is contained in:
Noah Husby 2024-07-10 17:02:33 -04:00 committed by GitHub
parent 924e767736
commit abeac3f3aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 489 additions and 29 deletions

View File

@ -1208,6 +1208,8 @@ build.json @home-assistant/supervisor
/tests/components/rtsp_to_webrtc/ @allenporter
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
/homeassistant/components/russound_rio/ @noahhusby
/tests/components/russound_rio/ @noahhusby
/homeassistant/components/ruuvi_gateway/ @akx
/tests/components/ruuvi_gateway/ @akx
/homeassistant/components/ruuvitag_ble/ @akx

View File

@ -1 +1,45 @@
"""The russound_rio component."""
import asyncio
import logging
from aiorussound import Russound
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError
from .const import CONNECT_TIMEOUT, RUSSOUND_RIO_EXCEPTIONS
PLATFORMS = [Platform.MEDIA_PLAYER]
_LOGGER = logging.getLogger(__name__)
type RussoundConfigEntry = ConfigEntry[Russound]
async def async_setup_entry(hass: HomeAssistant, entry: RussoundConfigEntry) -> bool:
"""Set up a config entry."""
russ = Russound(hass.loop, entry.data[CONF_HOST], entry.data[CONF_PORT])
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
except RUSSOUND_RIO_EXCEPTIONS as err:
raise ConfigEntryError(err) from err
entry.runtime_data = russ
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.close()
return unload_ok

View File

@ -0,0 +1,114 @@
"""Config flow to configure russound_rio component."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from aiorussound import Russound
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.helpers import config_validation as cv
from .const import (
CONNECT_TIMEOUT,
DOMAIN,
RUSSOUND_RIO_EXCEPTIONS,
NoPrimaryControllerException,
)
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PORT, default=9621): cv.port,
}
)
_LOGGER = logging.getLogger(__name__)
def find_primary_controller_metadata(
controllers: list[tuple[int, str, str]],
) -> tuple[str, str]:
"""Find the mac address of the primary Russound controller."""
for controller_id, mac_address, controller_type in controllers:
# The integration only cares about the primary controller linked by IP and not any downstream controllers
if controller_id == 1:
return (mac_address, controller_type)
raise NoPrimaryControllerException
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Russound RIO configuration flow."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
controllers = None
russ = Russound(self.hass.loop, host, port)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
controllers = await russ.enumerate_controllers()
metadata = find_primary_controller_metadata(controllers)
await russ.close()
except RUSSOUND_RIO_EXCEPTIONS:
_LOGGER.exception("Could not connect to Russound RIO")
errors["base"] = "cannot_connect"
except NoPrimaryControllerException:
_LOGGER.exception(
"Russound RIO device doesn't have a primary controller",
)
errors["base"] = "no_primary_controller"
else:
await self.async_set_unique_id(metadata[0])
self._abort_if_unique_id_configured()
data = {CONF_HOST: host, CONF_PORT: port}
return self.async_create_entry(title=metadata[1], data=data)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(
self, import_config: dict[str, Any]
) -> ConfigFlowResult:
"""Attempt to import the existing configuration."""
self._async_abort_entries_match({CONF_HOST: import_config[CONF_HOST]})
host = import_config[CONF_HOST]
port = import_config.get(CONF_PORT, 9621)
# Connection logic is repeated here since this method will be removed in future releases
russ = Russound(self.hass.loop, host, port)
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await russ.connect()
controllers = await russ.enumerate_controllers()
metadata = find_primary_controller_metadata(controllers)
await russ.close()
except RUSSOUND_RIO_EXCEPTIONS:
_LOGGER.exception("Could not connect to Russound RIO")
return self.async_abort(
reason="cannot_connect", description_placeholders={}
)
except NoPrimaryControllerException:
_LOGGER.exception("Russound RIO device doesn't have a primary controller")
return self.async_abort(
reason="no_primary_controller", description_placeholders={}
)
else:
await self.async_set_unique_id(metadata[0])
self._abort_if_unique_id_configured()
data = {CONF_HOST: host, CONF_PORT: port}
return self.async_create_entry(title=metadata[1], data=data)

View File

@ -0,0 +1,21 @@
"""Constants used for Russound RIO."""
import asyncio
from aiorussound import CommandException
DOMAIN = "russound_rio"
RUSSOUND_RIO_EXCEPTIONS = (
CommandException,
ConnectionRefusedError,
TimeoutError,
asyncio.CancelledError,
)
class NoPrimaryControllerException(Exception):
"""Thrown when the Russound device is not the primary unit in the RNET stack."""
CONNECT_TIMEOUT = 5

View File

@ -1,7 +1,8 @@
{
"domain": "russound_rio",
"name": "Russound RIO",
"codeowners": [],
"codeowners": ["@noahhusby"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
"iot_class": "local_push",
"loggers": ["aiorussound"],

View File

@ -2,34 +2,26 @@
from __future__ import annotations
from aiorussound import Russound
import voluptuous as vol
import logging
from homeassistant.components.media_player import (
PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
MediaType,
)
from homeassistant.const import (
CONF_HOST,
CONF_NAME,
CONF_PORT,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Required(CONF_NAME): cv.string,
vol.Optional(CONF_PORT, default=9621): cv.port,
}
)
from . import RussoundConfigEntry
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_setup_platform(
@ -40,22 +32,69 @@ async def async_setup_platform(
) -> None:
"""Set up the Russound RIO platform."""
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=config,
)
if (
result["type"] is FlowResultType.CREATE_ENTRY
or result["reason"] == "single_instance_allowed"
):
async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Russound RIO",
},
)
return
async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result['reason']}",
breaks_in_ha_version="2025.2.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result['reason']}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Russound RIO",
},
)
russ = Russound(hass.loop, host, port)
await russ.connect()
async def async_setup_entry(
hass: HomeAssistant,
entry: RussoundConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the Russound RIO platform."""
russ = entry.runtime_data
# Discover sources and zones
sources = await russ.enumerate_sources()
valid_zones = await russ.enumerate_zones()
devices = []
entities = []
for zone_id, name in valid_zones:
if zone_id.controller > 6:
_LOGGER.debug(
"Zone ID %s exceeds RIO controller maximum, skipping",
zone_id.device_str(),
)
continue
await russ.watch_zone(zone_id)
dev = RussoundZoneDevice(russ, zone_id, name, sources)
devices.append(dev)
zone = RussoundZoneDevice(russ, zone_id, name, sources)
entities.append(zone)
@callback
def on_stop(event):
@ -64,7 +103,7 @@ async def async_setup_platform(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop)
async_add_entities(devices)
async_add_entities(entities)
class RussoundZoneDevice(MediaPlayerEntity):
@ -80,7 +119,7 @@ class RussoundZoneDevice(MediaPlayerEntity):
| MediaPlayerEntityFeature.SELECT_SOURCE
)
def __init__(self, russ, zone_id, name, sources):
def __init__(self, russ, zone_id, name, sources) -> None:
"""Initialize the zone device."""
super().__init__()
self._name = name

View File

@ -0,0 +1,40 @@
{
"common": {
"error_cannot_connect": "Failed to connect to Russound device. Please make sure the device is powered up and connected to the network. Try power-cycling the device if it does not connect.",
"error_no_primary_controller": "No primary controller was detected for the Russound device. Please make sure that the target Russound device has it's controller ID set to 1 (using the selector on the back of the unit)."
},
"config": {
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]",
"name": "[%key:common::config_flow::data::name%]",
"port": "[%key:common::config_flow::data::port%]"
}
}
},
"error": {
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]",
"no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]"
},
"abort": {
"cannot_connect": "[%key:component::russound_rio::common::error_cannot_connect%]",
"no_primary_controller": "[%key:component::russound_rio::common::error_no_primary_controller%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The {integration_title} YAML configuration import cannot connect to the Russound device",
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nPlease make sure {integration_title} is turned on, and restart Home Assistant to try importing again. Otherwise, please remove the YAML from your configuration and add the integration manually."
},
"deprecated_yaml_import_issue_no_primary_controller": {
"title": "The {integration_title} YAML configuration import cannot configure the Russound Device.",
"description": "Configuring {integration_title} using YAML is being removed but there was a connection error importing your YAML configuration.\n\nNo primary controller was detected for the Russound device. Please make sure that the target Russound device has it's controller ID set to 1 (using the selector on the back of the unit)."
},
"deprecated_yaml_import_issue_unknown": {
"title": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::title%]",
"description": "[%key:component::russound_rio::issues::deprecated_yaml_import_issue_cannot_connect::description%]"
}
}
}

View File

@ -476,6 +476,7 @@ FLOWS = {
"rpi_power",
"rtsp_to_webrtc",
"ruckus_unleashed",
"russound_rio",
"ruuvi_gateway",
"ruuvitag_ble",
"rympro",

View File

@ -5158,7 +5158,7 @@
"integrations": {
"russound_rio": {
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_push",
"name": "Russound RIO"
},

View File

@ -328,6 +328,9 @@ aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.34
# homeassistant.components.russound_rio
aiorussound==1.1.2
# homeassistant.components.ruuvi_gateway
aioruuvigateway==0.1.0

View File

@ -0,0 +1 @@
"""Tests for the Russound RIO integration."""

View File

@ -0,0 +1,48 @@
"""Test fixtures for Russound RIO integration."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.russound_rio.const import DOMAIN
from homeassistant.core import HomeAssistant
from .const import HARDWARE_MAC, MOCK_CONFIG, MODEL
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry():
"""Prevent setup."""
with patch(
"homeassistant.components.russound_rio.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Mock a Russound RIO config entry."""
entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, unique_id=HARDWARE_MAC, title=MODEL
)
entry.add_to_hass(hass)
return entry
@pytest.fixture
def mock_russound() -> Generator[AsyncMock]:
"""Mock the Russound RIO client."""
with (
patch(
"homeassistant.components.russound_rio.Russound", autospec=True
) as mock_client,
patch(
"homeassistant.components.russound_rio.config_flow.Russound",
return_value=mock_client,
),
):
mock_client.enumerate_controllers.return_value = [(1, HARDWARE_MAC, MODEL)]
yield mock_client

View File

@ -0,0 +1,11 @@
"""Constants for russound_rio tests."""
HOST = "127.0.0.1"
PORT = 9621
MODEL = "MCA-C5"
HARDWARE_MAC = "00:11:22:33:44:55"
MOCK_CONFIG = {
"host": HOST,
"port": PORT,
}

View File

@ -0,0 +1,135 @@
"""Test the Russound RIO config flow."""
from unittest.mock import AsyncMock
from homeassistant.components.russound_rio.const import DOMAIN
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .const import HARDWARE_MAC, MOCK_CONFIG, MODEL
async def test_form(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MODEL
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None:
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
mock_russound.connect.side_effect = TimeoutError
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# Recover with correct information
mock_russound.connect.side_effect = None
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MODEL
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_no_primary_controller(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None:
"""Test we handle no primary controller error."""
mock_russound.enumerate_controllers.return_value = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
user_input = MOCK_CONFIG
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "no_primary_controller"}
# Recover with correct information
mock_russound.enumerate_controllers.return_value = [(1, HARDWARE_MAC, MODEL)]
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
MOCK_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MODEL
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_import(
hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound: AsyncMock
) -> None:
"""Test we import a config entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=MOCK_CONFIG,
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == MODEL
assert result["data"] == MOCK_CONFIG
assert len(mock_setup_entry.mock_calls) == 1
async def test_import_cannot_connect(
hass: HomeAssistant, mock_russound: AsyncMock
) -> None:
"""Test we handle import cannot connect error."""
mock_russound.connect.side_effect = TimeoutError
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
async def test_import_no_primary_controller(
hass: HomeAssistant, mock_russound: AsyncMock
) -> None:
"""Test import with no primary controller error."""
mock_russound.enumerate_controllers.return_value = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_CONFIG
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_primary_controller"