diff --git a/homeassistant/components/russound_rio/config_flow.py b/homeassistant/components/russound_rio/config_flow.py index 80da8c903d34db79554ae428a2949294a6db2e43..5618a424726b18d618018072786749e712a6bd36 100644 --- a/homeassistant/components/russound_rio/config_flow.py +++ b/homeassistant/components/russound_rio/config_flow.py @@ -8,12 +8,13 @@ from typing import Any from aiorussound import RussoundClient, RussoundTcpConnectionHandler import voluptuous as vol +from homeassistant.components import zeroconf from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigFlow, ConfigFlowResult, ) -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.helpers import config_validation as cv from .const import DOMAIN, RUSSOUND_RIO_EXCEPTIONS @@ -33,6 +34,53 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize the config flow.""" + self.data: dict[str, Any] = {} + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self.data[CONF_HOST] = host = discovery_info.host + self.data[CONF_PORT] = port = discovery_info.port or 9621 + + client = RussoundClient(RussoundTcpConnectionHandler(host, port)) + try: + await client.connect() + controller = client.controllers[1] + await client.disconnect() + except RUSSOUND_RIO_EXCEPTIONS: + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(controller.mac_address) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + self.data[CONF_NAME] = controller.controller_type + + self.context["title_placeholders"] = { + "name": self.data[CONF_NAME], + } + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self.data[CONF_NAME], + data={CONF_HOST: self.data[CONF_HOST], CONF_PORT: self.data[CONF_PORT]}, + ) + + self._set_confirm_only() + return self.async_show_form( + step_id="discovery_confirm", + description_placeholders={ + "name": self.data[CONF_NAME], + }, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -51,7 +99,9 @@ class FlowHandler(ConfigFlow, domain=DOMAIN): _LOGGER.exception("Could not connect to Russound RIO") errors["base"] = "cannot_connect" else: - await self.async_set_unique_id(controller.mac_address) + await self.async_set_unique_id( + controller.mac_address, raise_on_progress=False + ) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch(reason="wrong_device") return self.async_update_reload_and_abort( diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json index 582dfa85a9a75b992a56e9ed2ffa9b929f4033f1..35c53ed71febd56f6e75aae5f6881a75e6ba45c0 100644 --- a/homeassistant/components/russound_rio/manifest.json +++ b/homeassistant/components/russound_rio/manifest.json @@ -7,5 +7,6 @@ "iot_class": "local_push", "loggers": ["aiorussound"], "quality_scale": "silver", - "requirements": ["aiorussound==4.2.0"] + "requirements": ["aiorussound==4.2.0"], + "zeroconf": ["_rio._tcp.local."] } diff --git a/homeassistant/components/russound_rio/quality_scale.yaml b/homeassistant/components/russound_rio/quality_scale.yaml index d2bf8065c1bf4254867d0e58f4c8f966cde52e26..02b1eaa6aae705bf5b40b8183891dd948a64e099 100644 --- a/homeassistant/components/russound_rio/quality_scale.yaml +++ b/homeassistant/components/russound_rio/quality_scale.yaml @@ -57,7 +57,7 @@ rules: status: exempt comment: | This integration doesn't have enough / noisy entities that warrant being disabled by default. - discovery: todo + discovery: done stale-devices: todo diagnostics: done exception-translations: done @@ -67,7 +67,7 @@ rules: There are no entities that require icons. reconfiguration-flow: done dynamic-devices: todo - discovery-update-info: todo + discovery-update-info: done repair-issues: status: exempt comment: | diff --git a/homeassistant/components/russound_rio/strings.json b/homeassistant/components/russound_rio/strings.json index ae4e2f7ffd2091cd874ded2df4b0d13fc34e61d5..534c321e631d6e7e75af669d5f162c25ca40289a 100644 --- a/homeassistant/components/russound_rio/strings.json +++ b/homeassistant/components/russound_rio/strings.json @@ -15,6 +15,9 @@ "port": "The port of the Russound controller." } }, + "discovery_confirm": { + "description": "Do you want to setup {name}?" + }, "reconfigure": { "description": "Reconfigure your Russound controller.", "data": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 66c576d8840ce31784226a9e78cbf3309f435c1c..0766e1ce01106ff59d1622109ce416e65ad22845 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -775,6 +775,11 @@ ZEROCONF = { }, }, ], + "_rio._tcp.local.": [ + { + "domain": "russound_rio", + }, + ], "_sideplay._tcp.local.": [ { "domain": "ecobee", diff --git a/tests/components/russound_rio/test_config_flow.py b/tests/components/russound_rio/test_config_flow.py index 060d5114f590fa5f36674390a35351be128dc1f5..51cbb9772dcb9e2bb5830ef8371ea0e300a4ed47 100644 --- a/tests/components/russound_rio/test_config_flow.py +++ b/tests/components/russound_rio/test_config_flow.py @@ -1,9 +1,11 @@ """Test the Russound RIO config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from homeassistant.components.russound_rio.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER, ConfigFlowResult +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF, ConfigFlowResult from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -12,6 +14,23 @@ from .const import MOCK_CONFIG, MOCK_RECONFIGURATION_CONFIG, MODEL from tests.common import MockConfigEntry +ZEROCONF_DISCOVERY = ZeroconfServiceInfo( + ip_address=ip_address("192.168.20.17"), + ip_addresses=[ip_address("192.168.20.17")], + hostname="controller1.local.", + name="controller1._stream-magic._tcp.local.", + port=9621, + type="_rio._tcp.local.", + properties={ + "txtvers": "0", + "productType": "2", + "productId": "59", + "version": "07.04.00", + "buildDate": "Jul 8 2019", + "localName": "0", + }, +) + async def test_form( hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_russound_client: AsyncMock @@ -89,6 +108,159 @@ async def test_duplicate( assert result["reason"] == "already_configured" +async def test_zeroconf_flow( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "MCA-C5" + assert result["data"] == { + CONF_HOST: "192.168.20.17", + CONF_PORT: 9621, + } + assert result["result"].unique_id == "00:11:22:33:44:55" + + +async def test_zeroconf_flow_errors( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test zeroconf flow.""" + mock_russound_client.connect.side_effect = TimeoutError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_russound_client.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "discovery_confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "MCA-C5" + assert result["data"] == { + CONF_HOST: "192.168.20.17", + CONF_PORT: 9621, + } + assert result["result"].unique_id == "00:11:22:33:44:55" + + +async def test_zeroconf_duplicate( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_duplicate_different_ip( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate flow with different IP.""" + mock_config_entry.add_to_hass(hass) + + ZEROCONF_DISCOVERY_DIFFERENT_IP = ZeroconfServiceInfo( + ip_address=ip_address("192.168.20.18"), + ip_addresses=[ip_address("192.168.20.18")], + hostname="controller1.local.", + name="controller1._stream-magic._tcp.local.", + port=9621, + type="_rio._tcp.local.", + properties={ + "txtvers": "0", + "productType": "2", + "productId": "59", + "version": "07.04.00", + "buildDate": "Jul 8 2019", + "localName": "0", + }, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY_DIFFERENT_IP, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id) + assert entry + assert entry.data == { + CONF_HOST: "192.168.20.18", + CONF_PORT: 9621, + } + + +async def test_user_flow_works_discovery( + hass: HomeAssistant, + mock_russound_client: AsyncMock, + mock_setup_entry: AsyncMock, +) -> None: + """Test user flow can continue after discovery happened.""" + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZEROCONF_DISCOVERY, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 2 + 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 not hass.config_entries.flow.async_progress(DOMAIN) + + async def _start_reconfigure_flow( hass: HomeAssistant, mock_config_entry: MockConfigEntry ) -> ConfigFlowResult: