Squeezebox config flow (#35669)

* Squeezebox add config flow and player discovery

* Fixes to config flow

* Unavailable player detection and recovery

* Improved error message for auth failure

* Testing for squeezebox config flow

* Import configuration.yaml

* Support for discovery integration

* Internal server discovery

* Fix bug restoring previously detected squeezebox player

* Tests for user and edit steps in config flow

* Tests for import config flow

* Additional config flow tests and fixes

* Linter fixes

* Check that players are found before iterating them

* Remove noisy logger message

* Update requirements_all after rebase

* Use asyncio.Event in discovery task

* Use common keys in strings.json

* Bump pysqueezebox to v0.2.2 for fixed server discovery using python3.7

* Bump pysqueezebox version to v0.2.3

* Don't trap AbortFlow exception

Co-authored-by: J. Nick Koston <nick@koston.org>

* Refactor validate_input

* Update squeezebox tests

* Build data flow schema using function

* Fix linter error

* Updated en.json

* Update homeassistant/components/squeezebox/media_player.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/squeezebox/media_player.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/squeezebox/media_player.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update homeassistant/components/squeezebox/media_player.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update .coveragerc for squeezebox config flow test

* Mock TIMEOUT for faster testing

* More schema de-duplication and testing improvements

* Apply suggestions from code review

Co-authored-by: J. Nick Koston <nick@koston.org>

* Testing and config flow improvements

* Remove unused exceptions

* Remove deprecated logger message

* Update homeassistant/components/squeezebox/media_player.py

Co-authored-by: J. Nick Koston <nick@koston.org>

* Implement suggestions from code review

* Add async_unload_entry

* Use MockConfigEntry in squeezebox tests

* Remove unnecessary config schema

* Stop server discovery task when last config entry unloaded

* Improvements to async_unload_entry

* Fix bug in _discovery arguments

* Do not await server discovery in async_setup_entry

* Do not await start server discovery in async_setup

* Do not start server discovery from async_setup_entry until homeassistant running

* Re-detect players when server removed and re-added without restart

* Use entry.entry_id instead of unique_id

* Update unittests to avoid patching homeassistant code

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
rajlaud 2020-06-22 09:29:01 -05:00 committed by GitHub
parent e25f216fd6
commit 3f427602ba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 754 additions and 80 deletions

View File

@ -757,7 +757,8 @@ omit =
homeassistant/components/spotcrime/sensor.py
homeassistant/components/spotify/__init__.py
homeassistant/components/spotify/media_player.py
homeassistant/components/squeezebox/*
homeassistant/components/squeezebox/__init__.py
homeassistant/components/squeezebox/media_player.py
homeassistant/components/starline/*
homeassistant/components/starlingbank/sensor.py
homeassistant/components/steam_online/sensor.py

View File

@ -47,6 +47,7 @@ SERVICE_XIAOMI_GW = "xiaomi_gw"
CONFIG_ENTRY_HANDLERS = {
SERVICE_DAIKIN: "daikin",
SERVICE_TELLDUSLIVE: "tellduslive",
"logitech_mediaserver": "squeezebox",
}
SERVICE_HANDLERS = {
@ -64,7 +65,6 @@ SERVICE_HANDLERS = {
SERVICE_FREEBOX: ("freebox", None),
SERVICE_YEELIGHT: ("yeelight", None),
"yamaha": ("media_player", "yamaha"),
"logitech_mediaserver": ("media_player", "squeezebox"),
"frontier_silicon": ("media_player", "frontier_silicon"),
"openhome": ("media_player", "openhome"),
"bose_soundtouch": ("media_player", "soundtouch"),

View File

@ -1 +1,86 @@
"""The squeezebox component."""
"""The Logitech Squeezebox integration."""
import asyncio
import logging
from pysqueezebox import async_discover
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
from homeassistant.config_entries import SOURCE_DISCOVERY, ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START
from homeassistant.core import HomeAssistant
from .const import DOMAIN, ENTRY_PLAYERS, KNOWN_PLAYERS, PLAYER_DISCOVERY_UNSUB
_LOGGER = logging.getLogger(__name__)
DISCOVERY_TASK = "discovery_task"
async def start_server_discovery(hass):
"""Start a server discovery task."""
def _discovered_server(server):
asyncio.create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_DISCOVERY},
data={
CONF_HOST: server.host,
CONF_PORT: int(server.port),
"uuid": server.uuid,
},
)
)
hass.data.setdefault(DOMAIN, {})
if DISCOVERY_TASK not in hass.data[DOMAIN]:
_LOGGER.debug("Adding server discovery task for squeezebox")
hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_task(
async_discover(_discovered_server)
)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Logitech Squeezebox component."""
if hass.is_running:
asyncio.create_task(start_server_discovery(hass))
else:
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, start_server_discovery(hass)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Logitech Squeezebox from a config entry."""
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, MP_DOMAIN)
)
return True
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
# Stop player discovery task for this config entry.
hass.data[DOMAIN][entry.entry_id][PLAYER_DISCOVERY_UNSUB]()
# Remove config entry's players from list of known players
entry_players = hass.data[DOMAIN][entry.entry_id][ENTRY_PLAYERS]
if entry_players:
for player in entry_players:
_LOGGER.debug("Remove entry player %s from list of known players.", player)
hass.data[DOMAIN][KNOWN_PLAYERS].remove(player)
# Remove stored data for this config entry
hass.data[DOMAIN].pop(entry.entry_id)
# Stop server discovery task if this is the last config entry.
current_entries = hass.config_entries.async_entries(DOMAIN)
if len(current_entries) == 1 and current_entries[0] == entry:
_LOGGER.debug("Stopping server discovery task")
hass.data[DOMAIN][DISCOVERY_TASK].cancel()
hass.data[DOMAIN].pop(DISCOVERY_TASK)
return await hass.config_entries.async_forward_entry_unload(entry, MP_DOMAIN)

View File

@ -0,0 +1,189 @@
"""Config flow for Logitech Squeezebox integration."""
import asyncio
import logging
from pysqueezebox import Server, async_discover
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
HTTP_UNAUTHORIZED,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
# pylint: disable=unused-import
from .const import DEFAULT_PORT, DOMAIN
_LOGGER = logging.getLogger(__name__)
TIMEOUT = 5
def _base_schema(discovery_info=None):
"""Generate base schema."""
base_schema = {}
if discovery_info and CONF_HOST in discovery_info:
base_schema.update(
{
vol.Required(
CONF_HOST,
description={"suggested_value": discovery_info[CONF_HOST]},
): str,
}
)
else:
base_schema.update({vol.Required(CONF_HOST): str})
if discovery_info and CONF_PORT in discovery_info:
base_schema.update(
{
vol.Required(
CONF_PORT,
default=DEFAULT_PORT,
description={"suggested_value": discovery_info[CONF_PORT]},
): int,
}
)
else:
base_schema.update({vol.Required(CONF_PORT, default=DEFAULT_PORT): int})
base_schema.update(
{vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str}
)
return vol.Schema(base_schema)
class SqueezeboxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Logitech Squeezebox."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
def __init__(self):
"""Initialize an instance of the squeezebox config flow."""
self.data_schema = _base_schema()
self.discovery_info = None
async def _discover(self, uuid=None):
"""Discover an unconfigured LMS server."""
self.discovery_info = None
discovery_event = asyncio.Event()
def _discovery_callback(server):
if server.uuid:
# ignore already configured uuids
for entry in self._async_current_entries():
if entry.unique_id == server.uuid:
return
self.discovery_info = {
CONF_HOST: server.host,
CONF_PORT: server.port,
"uuid": server.uuid,
}
_LOGGER.debug("Discovered server: %s", self.discovery_info)
discovery_event.set()
discovery_task = self.hass.async_create_task(
async_discover(_discovery_callback)
)
await discovery_event.wait()
discovery_task.cancel() # stop searching as soon as we find server
# update with suggested values from discovery
self.data_schema = _base_schema(self.discovery_info)
async def _validate_input(self, data):
"""
Validate the user input allows us to connect.
Retrieve unique id and abort if already configured.
"""
server = Server(
async_get_clientsession(self.hass),
data[CONF_HOST],
data[CONF_PORT],
data.get(CONF_USERNAME),
data.get(CONF_PASSWORD),
)
try:
status = await server.async_query("serverstatus")
if not status:
if server.http_status == HTTP_UNAUTHORIZED:
return "invalid_auth"
return "cannot_connect"
except Exception: # pylint: disable=broad-except
return "unknown"
if "uuid" in status:
await self.async_set_unique_id(status["uuid"])
self._abort_if_unique_id_configured()
async def async_step_user(self, user_input=None):
"""Handle a flow initialized by the user."""
errors = {}
if user_input and CONF_HOST in user_input:
# update with host provided by user
self.data_schema = _base_schema(user_input)
return await self.async_step_edit()
# no host specified, see if we can discover an unconfigured LMS server
try:
await asyncio.wait_for(self._discover(), timeout=TIMEOUT)
return await self.async_step_edit()
except asyncio.TimeoutError:
errors["base"] = "no_server_found"
# display the form
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Optional(CONF_HOST): str}),
errors=errors,
)
async def async_step_edit(self, user_input=None):
"""Edit a discovered or manually inputted server."""
errors = {}
if user_input:
error = await self._validate_input(user_input)
if error:
errors["base"] = error
else:
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
return self.async_show_form(
step_id="edit", data_schema=self.data_schema, errors=errors
)
async def async_step_import(self, config):
"""Import a config flow from configuration."""
error = await self._validate_input(config)
if error:
return self.async_abort(reason=error)
return self.async_create_entry(title=config[CONF_HOST], data=config)
async def async_step_discovery(self, discovery_info):
"""Handle discovery."""
_LOGGER.debug("Reached discovery flow with info: %s", discovery_info)
if "uuid" in discovery_info:
await self.async_set_unique_id(discovery_info.pop("uuid"))
self._abort_if_unique_id_configured()
else:
# attempt to connect to server and determine uuid. will fail if password required
error = await self._validate_input(discovery_info)
if error:
await self._async_handle_discovery_without_unique_id()
# update schema with suggested values from discovery
self.data_schema = _base_schema(discovery_info)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
self.context.update({"title_placeholders": {"host": discovery_info[CONF_HOST]}})
return await self.async_step_edit()

View File

@ -1,10 +1,6 @@
"""Constants for the Squeezebox component."""
from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING
DOMAIN = "squeezebox"
SERVICE_CALL_METHOD = "call_method"
SQUEEZEBOX_MODE = {
"pause": STATE_PAUSED,
"play": STATE_PLAYING,
"stop": STATE_IDLE,
}
ENTRY_PLAYERS = "entry_players"
KNOWN_PLAYERS = "known_players"
PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub"
DEFAULT_PORT = 9000

View File

@ -2,6 +2,11 @@
"domain": "squeezebox",
"name": "Logitech Squeezebox",
"documentation": "https://www.home-assistant.io/integrations/squeezebox",
"codeowners": ["@rajlaud"],
"requirements": ["pysqueezebox==0.2.1"]
"codeowners": [
"@rajlaud"
],
"requirements": [
"pysqueezebox==0.2.4"
],
"config_flow": true
}

View File

@ -1,10 +1,11 @@
"""Support for interfacing to the Logitech SqueezeBox API."""
import asyncio
import logging
import socket
from pysqueezebox import Server
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE,
@ -28,14 +29,25 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
EVENT_HOMEASSISTANT_START,
STATE_IDLE,
STATE_OFF,
STATE_PAUSED,
STATE_PLAYING,
STATE_UNAVAILABLE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.dt import utcnow
from .const import SQUEEZEBOX_MODE
from .__init__ import start_server_discovery
from .const import (
DEFAULT_PORT,
DOMAIN,
ENTRY_PLAYERS,
KNOWN_PLAYERS,
PLAYER_DISCOVERY_UNSUB,
)
SERVICE_CALL_METHOD = "call_method"
SERVICE_CALL_QUERY = "call_query"
@ -47,8 +59,7 @@ ATTR_SYNC_GROUP = "sync_group"
_LOGGER = logging.getLogger(__name__)
DEFAULT_PORT = 9000
TIMEOUT = 10
DISCOVERY_INTERVAL = 60
SUPPORT_SQUEEZEBOX = (
SUPPORT_PAUSE
@ -65,21 +76,23 @@ SUPPORT_SQUEEZEBOX = (
| SUPPORT_CLEAR_PLAYLIST
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_USERNAME): cv.string,
}
PLATFORM_SCHEMA = vol.All(
cv.deprecated(CONF_HOST),
cv.deprecated(CONF_PORT),
cv.deprecated(CONF_PASSWORD),
cv.deprecated(CONF_USERNAME),
PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_USERNAME): cv.string,
}
),
)
DATA_SQUEEZEBOX = "squeezebox"
KNOWN_SERVERS = "squeezebox_known_servers"
KNOWN_SERVERS = "known_servers"
ATTR_PARAMETERS = "parameters"
ATTR_OTHER_PLAYER = "other_player"
ATTR_TO_PROPERTY = [
@ -87,57 +100,84 @@ ATTR_TO_PROPERTY = [
ATTR_SYNC_GROUP,
]
SQUEEZEBOX_MODE = {
"pause": STATE_PAUSED,
"play": STATE_PLAYING,
"stop": STATE_IDLE,
}
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the squeezebox platform."""
"""Set up squeezebox platform from platform entry in configuration.yaml (deprecated)."""
known_servers = hass.data.get(KNOWN_SERVERS)
if known_servers is None:
hass.data[KNOWN_SERVERS] = known_servers = set()
if config:
await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
)
if DATA_SQUEEZEBOX not in hass.data:
hass.data[DATA_SQUEEZEBOX] = []
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up an LMS Server from a config entry."""
config = config_entry.data
_LOGGER.debug("Reached async_setup_entry for host=%s", config[CONF_HOST])
username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
host = config[CONF_HOST]
port = config[CONF_PORT]
if discovery_info is not None:
host = discovery_info.get("host")
port = discovery_info.get("port")
else:
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN].setdefault(config_entry.entry_id, {})
# In case the port is not discovered
if port is None:
port = DEFAULT_PORT
known_players = hass.data[DOMAIN].get(KNOWN_PLAYERS)
if known_players is None:
hass.data[DOMAIN][KNOWN_PLAYERS] = known_players = []
# Get IP of host, to prevent duplication of same host (different DNS names)
try:
ipaddr = await hass.async_add_executor_job(socket.gethostbyname, host)
except OSError as error:
_LOGGER.error("Could not communicate with %s:%d: %s", host, port, error)
raise PlatformNotReady from error
entry_players = hass.data[DOMAIN][config_entry.entry_id].setdefault(
ENTRY_PLAYERS, []
)
if ipaddr in known_servers:
return
_LOGGER.debug("Creating LMS object for %s", ipaddr)
_LOGGER.debug("Creating LMS object for %s", host)
lms = Server(async_get_clientsession(hass), host, port, username, password)
known_servers.add(ipaddr)
players = await lms.async_get_players()
if players is None:
raise PlatformNotReady
media_players = []
for player in players:
media_players.append(SqueezeBoxDevice(player))
async def _discovery(now=None):
"""Discover squeezebox players by polling server."""
hass.data[DATA_SQUEEZEBOX].extend(media_players)
async_add_entities(media_players)
async def _discovered_player(player):
"""Handle a (re)discovered player."""
entity = next(
(
known
for known in known_players
if known.unique_id == player.player_id
),
None,
)
if entity and not entity.available:
# check if previously unavailable player has connected
await player.async_update()
entity.available = player.connected
if not entity:
_LOGGER.debug("Adding new entity: %s", player)
entity = SqueezeBoxEntity(player)
known_players.append(entity)
entry_players.append(entity)
async_add_entities([entity])
players = await lms.async_get_players()
if players:
for player in players:
hass.async_create_task(_discovered_player(player))
hass.data[DOMAIN][config_entry.entry_id][
PLAYER_DISCOVERY_UNSUB
] = hass.helpers.event.async_call_later(DISCOVERY_INTERVAL, _discovery)
_LOGGER.debug("Adding player discovery job for LMS server: %s", host)
asyncio.create_task(_discovery())
# Register entity services
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_CALL_METHOD,
{
@ -148,7 +188,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
},
"async_call_method",
)
platform.async_register_entity_service(
SERVICE_CALL_QUERY,
{
@ -159,17 +198,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
},
"async_call_query",
)
platform.async_register_entity_service(
SERVICE_SYNC, {vol.Required(ATTR_OTHER_PLAYER): cv.string}, "async_sync",
)
platform.async_register_entity_service(SERVICE_UNSYNC, None, "async_unsync")
# Start server discovery task if not already running
if hass.is_running:
asyncio.create_task(start_server_discovery(hass))
else:
hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, start_server_discovery(hass)
)
return True
class SqueezeBoxDevice(MediaPlayerEntity):
class SqueezeBoxEntity(MediaPlayerEntity):
"""
Representation of a SqueezeBox device.
@ -181,6 +226,7 @@ class SqueezeBoxDevice(MediaPlayerEntity):
self._player = player
self._last_update = None
self._query_result = {}
self._available = True
@property
def device_state_attributes(self):
@ -203,10 +249,22 @@ class SqueezeBoxDevice(MediaPlayerEntity):
"""Return a unique ID."""
return self._player.player_id
@property
def available(self):
"""Return True if device connected to LMS server."""
return self._available
@available.setter
def available(self, val):
"""Set available to True or False."""
self._available = bool(val)
@property
def state(self):
"""Return the state of the device."""
if self._player.power is not None and not self._player.power:
if not self.available:
return STATE_UNAVAILABLE
if not self._player.power:
return STATE_OFF
if self._player.mode:
return SQUEEZEBOX_MODE.get(self._player.mode)
@ -214,13 +272,15 @@ class SqueezeBoxDevice(MediaPlayerEntity):
async def async_update(self):
"""Update the Player() object."""
last_media_position = self.media_position
await self._player.async_update()
if self.media_position != last_media_position:
_LOGGER.debug(
"Media position updated for %s: %s", self, self.media_position
)
self._last_update = utcnow()
# only update available players, newly available players will be rediscovered and marked available
if self._available:
last_media_position = self.media_position
await self._player.async_update()
if self.media_position != last_media_position:
self._last_update = utcnow()
if self._player.connected is False:
_LOGGER.info("Player %s is not available", self.name)
self._available = False
@property
def volume_level(self):
@ -291,7 +351,9 @@ class SqueezeBoxDevice(MediaPlayerEntity):
@property
def sync_group(self):
"""List players we are synced with."""
player_ids = {p.unique_id: p.entity_id for p in self.hass.data[DATA_SQUEEZEBOX]}
player_ids = {
p.unique_id: p.entity_id for p in self.hass.data[DOMAIN][KNOWN_PLAYERS]
}
sync_group = []
for player in self._player.sync_group:
if player in player_ids:
@ -407,7 +469,9 @@ class SqueezeBoxDevice(MediaPlayerEntity):
If the other player is a member of a sync group, it will leave the current sync group
without asking.
"""
player_ids = {p.entity_id: p.unique_id for p in self.hass.data[DATA_SQUEEZEBOX]}
player_ids = {
p.entity_id: p.unique_id for p in self.hass.data[DOMAIN][KNOWN_PLAYERS]
}
other_player_id = player_ids.get(other_player)
if other_player_id:
await self._player.async_sync(other_player_id)

View File

@ -0,0 +1,33 @@
{
"title": "Logitech Squeezebox",
"config": {
"flow_title": "Logitech Squeezebox: {host}",
"step": {
"user": {
"title": "Configure Logitech Media Server",
"data": {
"host": "[%key:common::config_flow::data::host%]"
}
},
"edit": {
"title": "Edit connection information",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]",
"username": "[%key:common::config_flow::data::username%]",
"password": "[%key:common::config_flow::data::password%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]",
"no_server_found": "Could not automatically discover server."
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_server_found": "No LMS server found."
}
}
}

View File

@ -0,0 +1,33 @@
{
"config": {
"flow_title": "Logitech Squeezebox: {host}",
"abort": {
"already_configured": "Device is already configured",
"no_server_found": "No LMS server found."
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"no_server_found": "Could not automatically discover server.",
"unknown": "Unexpected error"
},
"step": {
"edit": {
"data": {
"host": "Host",
"password": "Password",
"port": "Port",
"username": "Username"
},
"title": "Edit connection information"
},
"user": {
"data": {
"host": "Host"
},
"title": "Configure Logitech Media Server"
}
}
},
"title": "Logitech Squeezebox"
}

View File

@ -148,6 +148,7 @@ FLOWS = [
"sonos",
"speedtestdotnet",
"spotify",
"squeezebox",
"starline",
"synology_dsm",
"tado",

View File

@ -1643,7 +1643,7 @@ pysonos==0.0.31
pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
pysqueezebox==0.2.1
pysqueezebox==0.2.4
# homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2

View File

@ -729,6 +729,9 @@ pysonos==0.0.31
# homeassistant.components.spc
pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
pysqueezebox==0.2.4
# homeassistant.components.ecobee
python-ecobee-api==0.2.5

View File

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

View File

@ -0,0 +1,263 @@
"""Test the Logitech Squeezebox config flow."""
from asynctest import patch
from pysqueezebox import Server
from homeassistant import config_entries
from homeassistant.components.squeezebox.const import DOMAIN
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_USERNAME,
HTTP_UNAUTHORIZED,
)
from homeassistant.data_entry_flow import (
RESULT_TYPE_ABORT,
RESULT_TYPE_CREATE_ENTRY,
RESULT_TYPE_FORM,
)
from tests.common import MockConfigEntry
HOST = "1.1.1.1"
HOST2 = "2.2.2.2"
PORT = 9000
UUID = "test-uuid"
UNKNOWN_ERROR = "1234"
async def mock_discover(_discovery_callback):
"""Mock discovering a Logitech Media Server."""
_discovery_callback(Server(None, HOST, PORT, uuid=UUID))
async def mock_failed_discover(_discovery_callback):
"""Mock unsuccessful discovery by doing nothing."""
async def patch_async_query_unauthorized(self, *args):
"""Mock an unauthorized query."""
self.http_status = HTTP_UNAUTHORIZED
return False
async def test_user_form(hass):
"""Test user-initiated flow, including discovery and the edit step."""
with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.squeezebox.async_setup_entry", return_value=True,
) as mock_setup_entry, patch(
"homeassistant.components.squeezebox.config_flow.async_discover", mock_discover
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "edit"
assert CONF_HOST in result["data_schema"].schema
for key in result["data_schema"].schema:
if key == CONF_HOST:
assert key.description == {"suggested_value": HOST}
# test the edit step
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{CONF_HOST: HOST, CONF_PORT: PORT, CONF_USERNAME: "", CONF_PASSWORD: ""},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
assert result["data"] == {
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "",
CONF_PASSWORD: "",
}
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_form_timeout(hass):
"""Test we handle server search timeout."""
with patch(
"homeassistant.components.squeezebox.config_flow.async_discover",
mock_failed_discover,
), patch("homeassistant.components.squeezebox.config_flow.TIMEOUT", 0.1):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": "no_server_found"}
# simulate manual input of host
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_HOST: HOST2}
)
assert result2["type"] == RESULT_TYPE_FORM
assert result2["step_id"] == "edit"
assert CONF_HOST in result2["data_schema"].schema
for key in result2["data_schema"].schema:
if key == CONF_HOST:
assert key.description == {"suggested_value": HOST2}
async def test_user_form_duplicate(hass):
"""Test duplicate discovered servers are skipped."""
with patch(
"homeassistant.components.squeezebox.config_flow.async_discover", mock_discover,
), patch("homeassistant.components.squeezebox.config_flow.TIMEOUT", 0.1), patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
), patch(
"homeassistant.components.squeezebox.async_setup_entry", return_value=True,
):
entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID)
await hass.config_entries.async_add(entry)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": "no_server_found"}
async def test_form_invalid_auth(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "edit"}
)
async def patch_async_query(self, *args):
self.http_status = HTTP_UNAUTHORIZED
return False
with patch("pysqueezebox.Server.async_query", new=patch_async_query):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "edit"}
)
with patch(
"pysqueezebox.Server.async_query", return_value=False,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "test-username",
CONF_PASSWORD: "test-password",
},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_discovery(hass):
"""Test handling of discovered server."""
with patch(
"pysqueezebox.Server.async_query", return_value={"uuid": UUID},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DISCOVERY},
data={CONF_HOST: HOST, CONF_PORT: PORT, "uuid": UUID},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "edit"
async def test_discovery_no_uuid(hass):
"""Test handling of discovered server with unavailable uuid."""
with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_DISCOVERY},
data={CONF_HOST: HOST, CONF_PORT: PORT},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "edit"
async def test_import(hass):
"""Test handling of configuration imported."""
with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.squeezebox.async_setup_entry", return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_HOST: HOST, CONF_PORT: PORT},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_import_bad_host(hass):
"""Test handling of configuration imported with bad host."""
with patch("pysqueezebox.Server.async_query", return_value=False):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_HOST: HOST, CONF_PORT: PORT},
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "cannot_connect"
async def test_import_bad_auth(hass):
"""Test handling of configuration import with bad authentication."""
with patch("pysqueezebox.Server.async_query", new=patch_async_query_unauthorized):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
CONF_HOST: HOST,
CONF_PORT: PORT,
CONF_USERNAME: "test",
CONF_PASSWORD: "bad",
},
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "invalid_auth"
async def test_import_existing(hass):
"""Test handling of configuration import of existing server."""
with patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
), patch(
"homeassistant.components.squeezebox.async_setup_entry", return_value=True,
), patch(
"pysqueezebox.Server.async_query", return_value={"ip": HOST, "uuid": UUID},
):
entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID)
await hass.config_entries.async_add(entry)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={CONF_HOST: HOST, CONF_PORT: PORT},
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"