From abeac3f3aa668a9a46ab5350409915013397ce72 Mon Sep 17 00:00:00 2001 From: Noah Husby <32528627+noahhusby@users.noreply.github.com> Date: Wed, 10 Jul 2024 17:02:33 -0400 Subject: [PATCH] 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 --- CODEOWNERS | 2 + .../components/russound_rio/__init__.py | 44 ++++++ .../components/russound_rio/config_flow.py | 114 +++++++++++++++ .../components/russound_rio/const.py | 21 +++ .../components/russound_rio/manifest.json | 3 +- .../components/russound_rio/media_player.py | 93 ++++++++---- .../components/russound_rio/strings.json | 40 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/russound_rio/__init__.py | 1 + tests/components/russound_rio/conftest.py | 48 +++++++ tests/components/russound_rio/const.py | 11 ++ .../russound_rio/test_config_flow.py | 135 ++++++++++++++++++ 14 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/russound_rio/config_flow.py create mode 100644 homeassistant/components/russound_rio/const.py create mode 100644 homeassistant/components/russound_rio/strings.json create mode 100644 tests/components/russound_rio/__init__.py create mode 100644 tests/components/russound_rio/conftest.py create mode 100644 tests/components/russound_rio/const.py create mode 100644 tests/components/russound_rio/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 25a21c55b63..3eda36c247c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py index 6d7fe3b1215..1560a4cd332 100644 --- a/homeassistant/components/russound_rio/__init__.py +++ b/homeassistant/components/russound_rio/__init__.py @@ -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 diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py new file mode 100644 index 00000000000..9ad0d25ff94 --- /dev/null +++ b/homeassistant/components/russound_rio/config_flow.py @@ -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) diff --git a/homeassistant/components/russound_rio/const.py b/homeassistant/components/russound_rio/const.py new file mode 100644 index 00000000000..e5bf81e464a --- /dev/null +++ b/homeassistant/components/russound_rio/const.py @@ -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 diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 418e9689d83..43cf8e7850f 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -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"], diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py index 334fccc08ab..e3eae51eb9e 100644 --- a/homeassistant/components/russound_rio/media_player.py +++ b/homeassistant/components/russound_rio/media_player.py @@ -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 diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json new file mode 100644 index 00000000000..a8b89e3dae3 --- /dev/null +++ b/homeassistant/components/russound_rio/strings.json @@ -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%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 308b27c8975..c1c8474fa31 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -476,6 +476,7 @@ FLOWS = { "rpi_power", "rtsp_to_webrtc", "ruckus_unleashed", + "russound_rio", "ruuvi_gateway", "ruuvitag_ble", "rympro", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 313c0cf24ca..7df69226e4c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5158,7 +5158,7 @@ "integrations": { "russound_rio": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push", "name": "Russound RIO" }, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edbcbf57631..d17e0e2e501 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/russound_rio/__init__.py b/tests/components/russound_rio/__init__.py new file mode 100644 index 00000000000..96171071907 --- /dev/null +++ b/tests/components/russound_rio/__init__.py @@ -0,0 +1 @@ +"""Tests for the Russound RIO integration.""" diff --git a/tests/components/russound_rio/conftest.py b/tests/components/russound_rio/conftest.py new file mode 100644 index 00000000000..49cb719dfc2 --- /dev/null +++ b/tests/components/russound_rio/conftest.py @@ -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 diff --git a/tests/components/russound_rio/const.py b/tests/components/russound_rio/const.py new file mode 100644 index 00000000000..92aed6494d9 --- /dev/null +++ b/tests/components/russound_rio/const.py @@ -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, +} diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py new file mode 100644 index 00000000000..195e4af9b11 --- /dev/null +++ b/tests/components/russound_rio/test_config_flow.py @@ -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" -- GitLab