mirror of
https://github.com/home-assistant/core.git
synced 2025-07-27 15:17:35 +00:00
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:
parent
924e767736
commit
abeac3f3aa
@ -1208,6 +1208,8 @@ build.json @home-assistant/supervisor
|
|||||||
/tests/components/rtsp_to_webrtc/ @allenporter
|
/tests/components/rtsp_to_webrtc/ @allenporter
|
||||||
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565
|
||||||
/tests/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
|
/homeassistant/components/ruuvi_gateway/ @akx
|
||||||
/tests/components/ruuvi_gateway/ @akx
|
/tests/components/ruuvi_gateway/ @akx
|
||||||
/homeassistant/components/ruuvitag_ble/ @akx
|
/homeassistant/components/ruuvitag_ble/ @akx
|
||||||
|
@ -1 +1,45 @@
|
|||||||
"""The russound_rio component."""
|
"""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
|
||||||
|
114
homeassistant/components/russound_rio/config_flow.py
Normal file
114
homeassistant/components/russound_rio/config_flow.py
Normal 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)
|
21
homeassistant/components/russound_rio/const.py
Normal file
21
homeassistant/components/russound_rio/const.py
Normal 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
|
@ -1,7 +1,8 @@
|
|||||||
{
|
{
|
||||||
"domain": "russound_rio",
|
"domain": "russound_rio",
|
||||||
"name": "Russound RIO",
|
"name": "Russound RIO",
|
||||||
"codeowners": [],
|
"codeowners": ["@noahhusby"],
|
||||||
|
"config_flow": true,
|
||||||
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
"documentation": "https://www.home-assistant.io/integrations/russound_rio",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aiorussound"],
|
"loggers": ["aiorussound"],
|
||||||
|
@ -2,34 +2,26 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from aiorussound import Russound
|
import logging
|
||||||
import voluptuous as vol
|
|
||||||
|
|
||||||
from homeassistant.components.media_player import (
|
from homeassistant.components.media_player import (
|
||||||
PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
|
|
||||||
MediaPlayerEntity,
|
MediaPlayerEntity,
|
||||||
MediaPlayerEntityFeature,
|
MediaPlayerEntityFeature,
|
||||||
MediaPlayerState,
|
MediaPlayerState,
|
||||||
MediaType,
|
MediaType,
|
||||||
)
|
)
|
||||||
from homeassistant.const import (
|
from homeassistant.config_entries import SOURCE_IMPORT
|
||||||
CONF_HOST,
|
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||||
CONF_NAME,
|
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||||
CONF_PORT,
|
from homeassistant.data_entry_flow import FlowResultType
|
||||||
EVENT_HOMEASSISTANT_STOP,
|
|
||||||
)
|
|
||||||
from homeassistant.core import HomeAssistant, callback
|
|
||||||
import homeassistant.helpers.config_validation as cv
|
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
|
from . import RussoundConfigEntry
|
||||||
{
|
from .const import DOMAIN
|
||||||
vol.Required(CONF_HOST): cv.string,
|
|
||||||
vol.Required(CONF_NAME): cv.string,
|
_LOGGER = logging.getLogger(__name__)
|
||||||
vol.Optional(CONF_PORT, default=9621): cv.port,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_platform(
|
async def async_setup_platform(
|
||||||
@ -40,22 +32,69 @@ async def async_setup_platform(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Set up the Russound RIO platform."""
|
"""Set up the Russound RIO platform."""
|
||||||
|
|
||||||
host = config.get(CONF_HOST)
|
result = await hass.config_entries.flow.async_init(
|
||||||
port = config.get(CONF_PORT)
|
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
|
# Discover sources and zones
|
||||||
sources = await russ.enumerate_sources()
|
sources = await russ.enumerate_sources()
|
||||||
valid_zones = await russ.enumerate_zones()
|
valid_zones = await russ.enumerate_zones()
|
||||||
|
|
||||||
devices = []
|
entities = []
|
||||||
for zone_id, name in valid_zones:
|
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)
|
await russ.watch_zone(zone_id)
|
||||||
dev = RussoundZoneDevice(russ, zone_id, name, sources)
|
zone = RussoundZoneDevice(russ, zone_id, name, sources)
|
||||||
devices.append(dev)
|
entities.append(zone)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def on_stop(event):
|
def on_stop(event):
|
||||||
@ -64,7 +103,7 @@ async def async_setup_platform(
|
|||||||
|
|
||||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop)
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop)
|
||||||
|
|
||||||
async_add_entities(devices)
|
async_add_entities(entities)
|
||||||
|
|
||||||
|
|
||||||
class RussoundZoneDevice(MediaPlayerEntity):
|
class RussoundZoneDevice(MediaPlayerEntity):
|
||||||
@ -80,7 +119,7 @@ class RussoundZoneDevice(MediaPlayerEntity):
|
|||||||
| MediaPlayerEntityFeature.SELECT_SOURCE
|
| MediaPlayerEntityFeature.SELECT_SOURCE
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, russ, zone_id, name, sources):
|
def __init__(self, russ, zone_id, name, sources) -> None:
|
||||||
"""Initialize the zone device."""
|
"""Initialize the zone device."""
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._name = name
|
self._name = name
|
||||||
|
40
homeassistant/components/russound_rio/strings.json
Normal file
40
homeassistant/components/russound_rio/strings.json
Normal 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%]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -476,6 +476,7 @@ FLOWS = {
|
|||||||
"rpi_power",
|
"rpi_power",
|
||||||
"rtsp_to_webrtc",
|
"rtsp_to_webrtc",
|
||||||
"ruckus_unleashed",
|
"ruckus_unleashed",
|
||||||
|
"russound_rio",
|
||||||
"ruuvi_gateway",
|
"ruuvi_gateway",
|
||||||
"ruuvitag_ble",
|
"ruuvitag_ble",
|
||||||
"rympro",
|
"rympro",
|
||||||
|
@ -5158,7 +5158,7 @@
|
|||||||
"integrations": {
|
"integrations": {
|
||||||
"russound_rio": {
|
"russound_rio": {
|
||||||
"integration_type": "hub",
|
"integration_type": "hub",
|
||||||
"config_flow": false,
|
"config_flow": true,
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"name": "Russound RIO"
|
"name": "Russound RIO"
|
||||||
},
|
},
|
||||||
|
@ -328,6 +328,9 @@ aioridwell==2024.01.0
|
|||||||
# homeassistant.components.ruckus_unleashed
|
# homeassistant.components.ruckus_unleashed
|
||||||
aioruckus==0.34
|
aioruckus==0.34
|
||||||
|
|
||||||
|
# homeassistant.components.russound_rio
|
||||||
|
aiorussound==1.1.2
|
||||||
|
|
||||||
# homeassistant.components.ruuvi_gateway
|
# homeassistant.components.ruuvi_gateway
|
||||||
aioruuvigateway==0.1.0
|
aioruuvigateway==0.1.0
|
||||||
|
|
||||||
|
1
tests/components/russound_rio/__init__.py
Normal file
1
tests/components/russound_rio/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""Tests for the Russound RIO integration."""
|
48
tests/components/russound_rio/conftest.py
Normal file
48
tests/components/russound_rio/conftest.py
Normal 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
|
11
tests/components/russound_rio/const.py
Normal file
11
tests/components/russound_rio/const.py
Normal 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,
|
||||||
|
}
|
135
tests/components/russound_rio/test_config_flow.py
Normal file
135
tests/components/russound_rio/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user