From f98487ef18978f70ebe5c898efec0916816688c9 Mon Sep 17 00:00:00 2001 From: Louis Christ Date: Sun, 28 Jul 2024 20:48:20 +0200 Subject: [PATCH] Add config_flow to bluesound integration (#115207) * Add config flow to bluesound * update init * abort flow if connection is not possible * add to codeowners * update unique id * add async_unload_entry * add import flow * add device_info * add zeroconf * fix errors * formatting * use bluos specific zeroconf service type * implement requested changes * implement requested changes * fix test; add more tests * use AsyncMock assert functions * fix potential naming collision * move setup_services back to media_player.py * implement requested changes * add port to zeroconf flow * Fix comments --------- Co-authored-by: Joostlek --- CODEOWNERS | 3 +- .../components/bluesound/__init__.py | 80 ++++++ .../components/bluesound/config_flow.py | 150 +++++++++++ homeassistant/components/bluesound/const.py | 3 + .../components/bluesound/manifest.json | 11 +- .../components/bluesound/media_player.py | 207 ++++++++++----- .../components/bluesound/strings.json | 26 ++ homeassistant/components/bluesound/utils.py | 8 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- homeassistant/generated/zeroconf.py | 5 + requirements_test_all.txt | 3 + tests/components/bluesound/__init__.py | 1 + tests/components/bluesound/conftest.py | 103 ++++++++ .../components/bluesound/test_config_flow.py | 247 ++++++++++++++++++ 15 files changed, 778 insertions(+), 72 deletions(-) create mode 100644 homeassistant/components/bluesound/config_flow.py create mode 100644 homeassistant/components/bluesound/utils.py create mode 100644 tests/components/bluesound/__init__.py create mode 100644 tests/components/bluesound/conftest.py create mode 100644 tests/components/bluesound/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 1b9808a418a8e..7db252a911753 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -197,7 +197,8 @@ build.json @home-assistant/supervisor /tests/components/bluemaestro/ @bdraco /homeassistant/components/blueprint/ @home-assistant/core /tests/components/blueprint/ @home-assistant/core -/homeassistant/components/bluesound/ @thrawnarn +/homeassistant/components/bluesound/ @thrawnarn @LouisChrist +/tests/components/bluesound/ @thrawnarn @LouisChrist /homeassistant/components/bluetooth/ @bdraco /tests/components/bluetooth/ @bdraco /homeassistant/components/bluetooth_adapters/ @bdraco diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py index 9dbe0f754fb04..0912a584fcef9 100644 --- a/homeassistant/components/bluesound/__init__.py +++ b/homeassistant/components/bluesound/__init__.py @@ -1 +1,81 @@ """The bluesound component.""" + +from dataclasses import dataclass + +import aiohttp +from pyblu import Player, SyncStatus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN +from .media_player import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +@dataclass +class BluesoundData: + """Bluesound data class.""" + + player: Player + sync_status: SyncStatus + + +type BluesoundConfigEntry = ConfigEntry[BluesoundData] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Bluesound.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = [] + setup_services(hass) + + return True + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: BluesoundConfigEntry +) -> bool: + """Set up the Bluesound entry.""" + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + session = async_get_clientsession(hass) + async with Player(host, port, session=session, default_timeout=10) as player: + try: + sync_status = await player.sync_status(timeout=1) + except TimeoutError as ex: + raise ConfigEntryNotReady( + f"Timeout while connecting to {host}:{port}" + ) from ex + except aiohttp.ClientError as ex: + raise ConfigEntryNotReady(f"Error connecting to {host}:{port}") from ex + + config_entry.runtime_data = BluesoundData(player, sync_status) + + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + player = None + for player in hass.data[DOMAIN]: + if player.unique_id == config_entry.unique_id: + break + + if player is None: + return False + + player.stop_polling() + hass.data[DOMAIN].remove(player) + + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/bluesound/config_flow.py b/homeassistant/components/bluesound/config_flow.py new file mode 100644 index 0000000000000..aae527187d2f3 --- /dev/null +++ b/homeassistant/components/bluesound/config_flow.py @@ -0,0 +1,150 @@ +"""Config flow for bluesound.""" + +import logging +from typing import Any + +import aiohttp +from pyblu import Player, SyncStatus +import voluptuous as vol + +from homeassistant.components import zeroconf +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN +from .media_player import DEFAULT_PORT +from .utils import format_unique_id + +_LOGGER = logging.getLogger(__name__) + + +class BluesoundConfigFlow(ConfigFlow, domain=DOMAIN): + """Bluesound config flow.""" + + VERSION = 1 + MINOR_VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._host: str | None = None + self._port = DEFAULT_PORT + self._sync_status: SyncStatus | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + session = async_get_clientsession(self.hass) + async with Player( + user_input[CONF_HOST], user_input[CONF_PORT], session=session + ) as player: + try: + sync_status = await player.sync_status(timeout=1) + except (TimeoutError, aiohttp.ClientError): + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id( + format_unique_id(sync_status.mac, user_input[CONF_PORT]) + ) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: user_input[CONF_HOST], + } + ) + + return self.async_create_entry( + title=sync_status.name, + data=user_input, + ) + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Optional(CONF_PORT, default=11000): int, + } + ), + ) + + async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult: + """Import bluesound config entry from configuration.yaml.""" + session = async_get_clientsession(self.hass) + async with Player( + import_data[CONF_HOST], import_data[CONF_PORT], session=session + ) as player: + try: + sync_status = await player.sync_status(timeout=1) + except (TimeoutError, aiohttp.ClientError): + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id( + format_unique_id(sync_status.mac, import_data[CONF_PORT]) + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=sync_status.name, + data=import_data, + ) + + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle a flow initialized by zeroconf discovery.""" + if discovery_info.port is not None: + self._port = discovery_info.port + + session = async_get_clientsession(self.hass) + try: + async with Player( + discovery_info.host, self._port, session=session + ) as player: + sync_status = await player.sync_status(timeout=1) + except (TimeoutError, aiohttp.ClientError): + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(format_unique_id(sync_status.mac, self._port)) + + self._host = discovery_info.host + self._sync_status = sync_status + + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self._host, + } + ) + + self.context.update( + { + "title_placeholders": {"name": sync_status.name}, + "configuration_url": f"http://{discovery_info.host}", + } + ) + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None) -> ConfigFlowResult: + """Confirm the zeroconf setup.""" + assert self._sync_status is not None + assert self._host is not None + + if user_input is not None: + return self.async_create_entry( + title=self._sync_status.name, + data={ + CONF_HOST: self._host, + CONF_PORT: self._port, + }, + ) + + return self.async_show_form( + step_id="confirm", + description_placeholders={ + "name": self._sync_status.name, + "host": self._host, + }, + ) diff --git a/homeassistant/components/bluesound/const.py b/homeassistant/components/bluesound/const.py index ae5291c651317..b7da4e3170212 100644 --- a/homeassistant/components/bluesound/const.py +++ b/homeassistant/components/bluesound/const.py @@ -1,7 +1,10 @@ """Constants for the Bluesound HiFi wireless speakers and audio integrations component.""" DOMAIN = "bluesound" +INTEGRATION_TITLE = "Bluesound" SERVICE_CLEAR_TIMER = "clear_sleep_timer" SERVICE_JOIN = "join" SERVICE_SET_TIMER = "set_sleep_timer" SERVICE_UNJOIN = "unjoin" +ATTR_BLUESOUND_GROUP = "bluesound_group" +ATTR_MASTER = "master" diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index e41a2ac21b9ee..64b8e8abffc83 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -1,8 +1,15 @@ { "domain": "bluesound", "name": "Bluesound", - "codeowners": ["@thrawnarn"], + "after_dependencies": ["zeroconf"], + "codeowners": ["@thrawnarn", "@LouisChrist"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bluesound", "iot_class": "local_polling", - "requirements": ["pyblu==0.4.0"] + "requirements": ["pyblu==0.4.0"], + "zeroconf": [ + { + "type": "_musc._tcp.local." + } + ] } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 52bbf813dccc6..e40a20f888a05 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -4,10 +4,11 @@ import asyncio from asyncio import CancelledError +from collections.abc import Callable from contextlib import suppress from datetime import datetime, timedelta import logging -from typing import Any, NamedTuple +from typing import TYPE_CHECKING, Any, NamedTuple from aiohttp.client_exceptions import ClientError from pyblu import Input, Player, Preset, Status, SyncStatus @@ -23,6 +24,7 @@ MediaType, async_process_play_media_url, ) +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, @@ -32,11 +34,16 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + Event, + HomeAssistant, + ServiceCall, + callback, +) +from homeassistant.data_entry_flow import FlowResultType from homeassistant.exceptions import ServiceValidationError -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -44,19 +51,23 @@ import homeassistant.util.dt as dt_util from .const import ( + ATTR_BLUESOUND_GROUP, + ATTR_MASTER, DOMAIN, + INTEGRATION_TITLE, SERVICE_CLEAR_TIMER, SERVICE_JOIN, SERVICE_SET_TIMER, SERVICE_UNJOIN, ) +from .utils import format_unique_id -_LOGGER = logging.getLogger(__name__) +if TYPE_CHECKING: + from . import BluesoundConfigEntry -ATTR_BLUESOUND_GROUP = "bluesound_group" -ATTR_MASTER = "master" +_LOGGER = logging.getLogger(__name__) -DATA_BLUESOUND = "bluesound" +DATA_BLUESOUND = DOMAIN DEFAULT_PORT = 11000 NODE_OFFLINE_CHECK_TIMEOUT = 180 @@ -83,6 +94,10 @@ } ) +BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) + +BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id}) + class ServiceMethodDetails(NamedTuple): """Details for SERVICE_TO_METHOD mapping.""" @@ -91,10 +106,6 @@ class ServiceMethodDetails(NamedTuple): schema: vol.Schema -BS_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -BS_JOIN_SCHEMA = BS_SCHEMA.extend({vol.Required(ATTR_MASTER): cv.entity_id}) - SERVICE_TO_METHOD = { SERVICE_JOIN: ServiceMethodDetails(method="async_join", schema=BS_JOIN_SCHEMA), SERVICE_UNJOIN: ServiceMethodDetails(method="async_unjoin", schema=BS_SCHEMA), @@ -107,34 +118,41 @@ class ServiceMethodDetails(NamedTuple): } -def _add_player(hass: HomeAssistant, async_add_entities, host, port=None, name=None): +def _add_player( + hass: HomeAssistant, + async_add_entities: AddEntitiesCallback, + host: str, + port: int, + player: Player, + sync_status: SyncStatus, +): """Add Bluesound players.""" @callback - def _init_player(event=None): + def _init_bluesound_player(event: Event | None = None): """Start polling.""" - hass.async_create_task(player.async_init()) + hass.async_create_task(bluesound_player.async_init()) @callback - def _start_polling(event=None): + def _start_polling(event: Event | None = None): """Start polling.""" - player.start_polling() + bluesound_player.start_polling() @callback - def _stop_polling(event=None): + def _stop_polling(event: Event | None = None): """Stop polling.""" - player.stop_polling() + bluesound_player.stop_polling() @callback - def _add_player_cb(): + def _add_bluesound_player_cb(): """Add player after first sync fetch.""" - if player.id in [x.id for x in hass.data[DATA_BLUESOUND]]: - _LOGGER.warning("Player already added %s", player.id) + if bluesound_player.id in [x.id for x in hass.data[DATA_BLUESOUND]]: + _LOGGER.warning("Player already added %s", bluesound_player.id) return - hass.data[DATA_BLUESOUND].append(player) - async_add_entities([player]) - _LOGGER.info("Added device with name: %s", player.name) + hass.data[DATA_BLUESOUND].append(bluesound_player) + async_add_entities([bluesound_player]) + _LOGGER.debug("Added device with name: %s", bluesound_player.name) if hass.is_running: _start_polling() @@ -143,42 +161,61 @@ def _add_player_cb(): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling) - player = BluesoundPlayer(hass, host, port, name, _add_player_cb) + bluesound_player = BluesoundPlayer( + hass, host, port, player, sync_status, _add_bluesound_player_cb + ) if hass.is_running: - _init_player() + _init_bluesound_player() else: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_bluesound_player) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Bluesound platforms.""" - if DATA_BLUESOUND not in hass.data: - hass.data[DATA_BLUESOUND] = [] - - if discovery_info: - _add_player( - hass, - async_add_entities, - discovery_info.get(CONF_HOST), - discovery_info.get(CONF_PORT), +async def _async_import(hass: HomeAssistant, config: ConfigType) -> None: + """Import config entry from configuration.yaml.""" + if not hass.config_entries.async_entries(DOMAIN): + # Start import flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - return - - if hosts := config.get(CONF_HOSTS): - for host in hosts: - _add_player( + if ( + result["type"] == FlowResultType.ABORT + and result["reason"] == "cannot_connect" + ): + ir.async_create_issue( hass, - async_add_entities, - host.get(CONF_HOST), - host.get(CONF_PORT), - host.get(CONF_NAME), + DOMAIN, + f"deprecated_yaml_import_issue_{result['reason']}", + breaks_in_ha_version="2025.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{result['reason']}", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, ) + return + + ir.async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2025.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": INTEGRATION_TITLE, + }, + ) + + +def setup_services(hass: HomeAssistant) -> None: + """Set up services for Bluesound component.""" async def async_service_handler(service: ServiceCall) -> None: """Map services to method of Bluesound devices.""" @@ -190,12 +227,10 @@ async def async_service_handler(service: ServiceCall) -> None: } if entity_ids := service.data.get(ATTR_ENTITY_ID): target_players = [ - player - for player in hass.data[DATA_BLUESOUND] - if player.entity_id in entity_ids + player for player in hass.data[DOMAIN] if player.entity_id in entity_ids ] else: - target_players = hass.data[DATA_BLUESOUND] + target_players = hass.data[DOMAIN] for player in target_players: await getattr(player, method.method)(**params) @@ -206,20 +241,61 @@ async def async_service_handler(service: ServiceCall) -> None: ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: BluesoundConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Bluesound entry.""" + host = config_entry.data[CONF_HOST] + port = config_entry.data[CONF_PORT] + + _add_player( + hass, + async_add_entities, + host, + port, + config_entry.runtime_data.player, + config_entry.runtime_data.sync_status, + ) + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None, +) -> None: + """Trigger import flows.""" + hosts = config.get(CONF_HOSTS, []) + for host in hosts: + import_data = { + CONF_HOST: host[CONF_HOST], + CONF_PORT: host.get(CONF_PORT, 11000), + } + hass.async_create_task(_async_import(hass, import_data)) + + class BluesoundPlayer(MediaPlayerEntity): """Representation of a Bluesound Player.""" _attr_media_content_type = MediaType.MUSIC def __init__( - self, hass: HomeAssistant, host, port=None, name=None, init_callback=None + self, + hass: HomeAssistant, + host: str, + port: int, + player: Player, + sync_status: SyncStatus, + init_callback: Callable[[], None], ) -> None: """Initialize the media player.""" self.host = host self._hass = hass self.port = port self._polling_task = None # The actual polling task. - self._name = name + self._name = sync_status.name self._id = None self._last_status_update = None self._sync_status: SyncStatus | None = None @@ -234,15 +310,10 @@ def __init__( self._group_name = None self._group_list: list[str] = [] self._bluesound_device_name = None - self._player = Player( - host, port, async_get_clientsession(hass), default_timeout=10 - ) + self._player = player self._init_callback = init_callback - if self.port is None: - self.port = DEFAULT_PORT - @staticmethod def _try_get_index(string, search_string): """Get the index.""" @@ -388,10 +459,10 @@ async def async_update_status(self): raise @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return an unique ID.""" assert self._sync_status is not None - return f"{format_mac(self._sync_status.mac)}-{self.port}" + return format_unique_id(self._sync_status.mac, self.port) async def async_trigger_sync_on_all(self): """Trigger sync status update on all devices.""" diff --git a/homeassistant/components/bluesound/strings.json b/homeassistant/components/bluesound/strings.json index f41c34a7449ac..c85014fedc36a 100644 --- a/homeassistant/components/bluesound/strings.json +++ b/homeassistant/components/bluesound/strings.json @@ -1,4 +1,30 @@ { + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "data_description": { + "host": "Hostname or IP address of your Bluesound player", + "port": "Port of your Bluesound player. This is usually 11000." + } + }, + "confirm": { + "title": "Discover Bluesound player", + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, "services": { "join": { "name": "Join", diff --git a/homeassistant/components/bluesound/utils.py b/homeassistant/components/bluesound/utils.py new file mode 100644 index 0000000000000..89a6fd1e78702 --- /dev/null +++ b/homeassistant/components/bluesound/utils.py @@ -0,0 +1,8 @@ +"""Utility functions for the Bluesound component.""" + +from homeassistant.helpers.device_registry import format_mac + + +def format_unique_id(mac: str, port: int) -> str: + """Generate a unique ID based on the MAC address and port number.""" + return f"{format_mac(mac)}-{port}" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 90f9675339bf1..1cea31202bc1a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -82,6 +82,7 @@ "blink", "blue_current", "bluemaestro", + "bluesound", "bluetooth", "bmw_connected_drive", "bond", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index dc1d203856c5c..74efe96dd2daf 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -725,7 +725,7 @@ "bluesound": { "name": "Bluesound", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling" }, "bluetooth": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index c53add1814ded..7cd60da2d0eac 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -651,6 +651,11 @@ "name": "yeelink-*", }, ], + "_musc._tcp.local.": [ + { + "domain": "bluesound", + }, + ], "_nanoleafapi._tcp.local.": [ { "domain": "nanoleaf", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b161b828ddc4..a949a12623f67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1405,6 +1405,9 @@ pybalboa==1.0.2 # homeassistant.components.blackbird pyblackbird==0.6 +# homeassistant.components.bluesound +pyblu==0.4.0 + # homeassistant.components.neato pybotvac==0.0.25 diff --git a/tests/components/bluesound/__init__.py b/tests/components/bluesound/__init__.py new file mode 100644 index 0000000000000..f8a3701422ec8 --- /dev/null +++ b/tests/components/bluesound/__init__.py @@ -0,0 +1 @@ +"""Tests for the Bluesound integration.""" diff --git a/tests/components/bluesound/conftest.py b/tests/components/bluesound/conftest.py new file mode 100644 index 0000000000000..02c73bcd62ff3 --- /dev/null +++ b/tests/components/bluesound/conftest.py @@ -0,0 +1,103 @@ +"""Common fixtures for the Bluesound tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyblu import SyncStatus +import pytest + +from homeassistant.components.bluesound.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def sync_status() -> SyncStatus: + """Return a sync status object.""" + return SyncStatus( + etag="etag", + id="1.1.1.1:11000", + mac="00:11:22:33:44:55", + name="player-name", + image="invalid_url", + initialized=True, + brand="brand", + model="model", + model_name="model-name", + volume_db=0.5, + volume=50, + group=None, + master=None, + slaves=None, + zone=None, + zone_master=None, + zone_slave=None, + mute_volume_db=None, + mute_volume=None, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.bluesound.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return a mocked config entry.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.2", + CONF_PORT: 11000, + }, + unique_id="00:11:22:33:44:55-11000", + ) + mock_entry.add_to_hass(hass) + + return mock_entry + + +@pytest.fixture +def mock_player() -> Generator[AsyncMock]: + """Mock the player.""" + with ( + patch( + "homeassistant.components.bluesound.Player", autospec=True + ) as mock_player, + patch( + "homeassistant.components.bluesound.config_flow.Player", + new=mock_player, + ), + ): + player = mock_player.return_value + player.__aenter__.return_value = player + player.status.return_value = None + player.sync_status.return_value = SyncStatus( + etag="etag", + id="1.1.1.1:11000", + mac="00:11:22:33:44:55", + name="player-name", + image="invalid_url", + initialized=True, + brand="brand", + model="model", + model_name="model-name", + volume_db=0.5, + volume=50, + group=None, + master=None, + slaves=None, + zone=None, + zone_master=None, + zone_slave=None, + mute_volume_db=None, + mute_volume=None, + ) + yield player diff --git a/tests/components/bluesound/test_config_flow.py b/tests/components/bluesound/test_config_flow.py new file mode 100644 index 0000000000000..32f36fcea58b9 --- /dev/null +++ b/tests/components/bluesound/test_config_flow.py @@ -0,0 +1,247 @@ +"""Test the Bluesound config flow.""" + +from unittest.mock import AsyncMock + +from aiohttp import ClientConnectionError + +from homeassistant.components.bluesound.const import DOMAIN +from homeassistant.components.zeroconf import ZeroconfServiceInfo +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_player: 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["errors"] == {} + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "player-name" + assert result["data"] == {CONF_HOST: "1.1.1.1", CONF_PORT: 11000} + assert result["result"].unique_id == "00:11:22:33:44:55-11000" + + mock_setup_entry.assert_called_once() + + +async def test_user_flow_cannot_connect( + hass: HomeAssistant, mock_player: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + mock_player.sync_status.side_effect = ClientConnectionError + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + assert result["step_id"] == "user" + + mock_player.sync_status.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "player-name" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: 11000, + } + + +async def test_user_flow_aleady_configured( + hass: HomeAssistant, + mock_player: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + CONF_PORT: 11000, + }, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "1.1.1.1" + + mock_player.sync_status.assert_called_once() + + +async def test_import_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_player: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "player-name" + assert result["data"] == {CONF_HOST: "1.1.1.1", CONF_PORT: 11000} + assert result["result"].unique_id == "00:11:22:33:44:55-11000" + + mock_setup_entry.assert_called_once() + mock_player.sync_status.assert_called_once() + + +async def test_import_flow_cannot_connect( + hass: HomeAssistant, mock_player: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + mock_player.sync_status.side_effect = ClientConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_player.sync_status.assert_called_once() + + +async def test_import_flow_already_configured( + hass: HomeAssistant, + mock_player: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: "1.1.1.1", CONF_PORT: 11000}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + mock_player.sync_status.assert_called_once() + + +async def test_zeroconf_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_player: AsyncMock +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address="1.1.1.1", + ip_addresses=["1.1.1.1"], + port=11000, + hostname="player-name", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + + mock_setup_entry.assert_not_called() + mock_player.sync_status.assert_called_once() + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "player-name" + assert result["data"] == {CONF_HOST: "1.1.1.1", CONF_PORT: 11000} + assert result["result"].unique_id == "00:11:22:33:44:55-11000" + + mock_setup_entry.assert_called_once() + + +async def test_zeroconf_flow_cannot_connect( + hass: HomeAssistant, mock_player: AsyncMock +) -> None: + """Test we handle cannot connect error.""" + mock_player.sync_status.side_effect = ClientConnectionError + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address="1.1.1.1", + ip_addresses=["1.1.1.1"], + port=11000, + hostname="player-name", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + mock_player.sync_status.assert_called_once() + + +async def test_zeroconf_flow_already_configured( + hass: HomeAssistant, + mock_player: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle already configured and update the host.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address="1.1.1.1", + ip_addresses=["1.1.1.1"], + port=11000, + hostname="player-name", + type="_musc._tcp.local.", + name="player-name._musc._tcp.local.", + properties={}, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert mock_config_entry.data[CONF_HOST] == "1.1.1.1" + + mock_player.sync_status.assert_called_once()