Skip to content

Commit

Permalink
Add config_flow to bluesound integration (home-assistant#115207)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
LouisChrist and joostlek authored Jul 28, 2024
1 parent dff9645 commit f98487e
Show file tree
Hide file tree
Showing 15 changed files with 778 additions and 72 deletions.
3 changes: 2 additions & 1 deletion CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 80 additions & 0 deletions homeassistant/components/bluesound/__init__.py
Original file line number Diff line number Diff line change
@@ -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)
150 changes: 150 additions & 0 deletions homeassistant/components/bluesound/config_flow.py
Original file line number Diff line number Diff line change
@@ -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,
},
)
3 changes: 3 additions & 0 deletions homeassistant/components/bluesound/const.py
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 9 additions & 2 deletions homeassistant/components/bluesound/manifest.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
Loading

0 comments on commit f98487e

Please sign in to comment.