Add discovery to Russound RIO (#134245)

This commit is contained in:
Noah Husby 2024-12-30 16:46:08 -05:00 committed by GitHub
parent a345e80368
commit b7541f098c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 237 additions and 6 deletions

View File

@ -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(

View File

@ -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."]
}

View File

@ -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: |

View File

@ -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": {

View File

@ -775,6 +775,11 @@ ZEROCONF = {
},
},
],
"_rio._tcp.local.": [
{
"domain": "russound_rio",
},
],
"_sideplay._tcp.local.": [
{
"domain": "ecobee",

View File

@ -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: