diff --git a/CODEOWNERS b/CODEOWNERS index 25a21c55b6328f8a70f78956bf48d89bcaeae120..3eda36c247c99d53f473b94aeb6fd3df5e94c871 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 6d7fe3b12152237f4e19ce8b3237f518a63750c3..1560a4cd332025f73f9bcdaac94aba62c3f894e1 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 0000000000000000000000000000000000000000..9ad0d25ff94a3a5afea9fd921f518a4219a7777e --- /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 0000000000000000000000000000000000000000..e5bf81e464a57c39c5a8a53ba79d341a11071504 --- /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 418e9689d839a7bf84cac8f7810aab0c99de83f0..43cf8e7850fa109f42150ed9faf50299777d3edd 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 334fccc08ab6c2aa7b7429f4470283b45bd8e399..e3eae51eb9ea44a686fba88056c2f4296c780162 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 0000000000000000000000000000000000000000..a8b89e3dae3cab60f3f57f081d89b0dae03ecd64 --- /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 308b27c897579649e1bb3796568a9c536f9a592a..c1c8474fa3166a8017e0980c123ded976449a24b 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 313c0cf24cac77e7563bfec4f43faffcfa44d30b..7df69226e4ccbd1861b1d9e6c394f0341e3c2946 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 edbcbf5763111f8084bc08b41292fc4c862cd5c4..d17e0e2e501ee2982d64e2898a782ad57a20d7fd 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 0000000000000000000000000000000000000000..96171071907eb0053a8feed7983a24b3fddd592f --- /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 0000000000000000000000000000000000000000..49cb719dfc2a7b0f91e248ac6d2ed881b0f7e16a --- /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 0000000000000000000000000000000000000000..92aed6494d9a5cceb8c3cbfcc57328abc48640b5 --- /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 0000000000000000000000000000000000000000..195e4af9b114e313738ecedd4ace6442ac2e164a --- /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"