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 <joostlek@outlook.com>
This commit is contained in:
Louis Christ
2024-07-28 20:48:20 +02:00
committed by GitHub
parent dff964582b
commit f98487ef18
15 changed files with 778 additions and 72 deletions

View File

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

View File

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

View File

@ -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,
},
)

View File

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

View File

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

View File

@ -4,10 +4,11 @@ from __future__ import annotations
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 @@ from homeassistant.components.media_player import (
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 @@ from homeassistant.const import (
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 @@ from homeassistant.util import Throttle
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
if TYPE_CHECKING:
from . import BluesoundConfigEntry
_LOGGER = logging.getLogger(__name__)
ATTR_BLUESOUND_GROUP = "bluesound_group"
ATTR_MASTER = "master"
DATA_BLUESOUND = "bluesound"
DATA_BLUESOUND = DOMAIN
DEFAULT_PORT = 11000
NODE_OFFLINE_CHECK_TIMEOUT = 180
@ -83,6 +94,10 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
}
)
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 @@ SERVICE_TO_METHOD = {
}
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(hass: HomeAssistant, async_add_entities, host, port=None, name=N
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_setup_platform(
}
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_setup_platform(
)
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 @@ class BluesoundPlayer(MediaPlayerEntity):
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 @@ class BluesoundPlayer(MediaPlayerEntity):
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."""

View File

@ -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",

View File

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

View File

@ -82,6 +82,7 @@ FLOWS = {
"blink",
"blue_current",
"bluemaestro",
"bluesound",
"bluetooth",
"bmw_connected_drive",
"bond",

View File

@ -725,7 +725,7 @@
"bluesound": {
"name": "Bluesound",
"integration_type": "hub",
"config_flow": false,
"config_flow": true,
"iot_class": "local_polling"
},
"bluetooth": {

View File

@ -651,6 +651,11 @@ ZEROCONF = {
"name": "yeelink-*",
},
],
"_musc._tcp.local.": [
{
"domain": "bluesound",
},
],
"_nanoleafapi._tcp.local.": [
{
"domain": "nanoleaf",

View File

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

View File

@ -0,0 +1 @@
"""Tests for the Bluesound integration."""

View File

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

View File

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