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/spotcrime/sensor.py
homeassistant/components/spotify/__init__.py homeassistant/components/spotify/__init__.py
homeassistant/components/spotify/media_player.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/starline/*
homeassistant/components/starlingbank/sensor.py homeassistant/components/starlingbank/sensor.py
homeassistant/components/steam_online/sensor.py homeassistant/components/steam_online/sensor.py

View File

@ -47,6 +47,7 @@ SERVICE_XIAOMI_GW = "xiaomi_gw"
CONFIG_ENTRY_HANDLERS = { CONFIG_ENTRY_HANDLERS = {
SERVICE_DAIKIN: "daikin", SERVICE_DAIKIN: "daikin",
SERVICE_TELLDUSLIVE: "tellduslive", SERVICE_TELLDUSLIVE: "tellduslive",
"logitech_mediaserver": "squeezebox",
} }
SERVICE_HANDLERS = { SERVICE_HANDLERS = {
@ -64,7 +65,6 @@ SERVICE_HANDLERS = {
SERVICE_FREEBOX: ("freebox", None), SERVICE_FREEBOX: ("freebox", None),
SERVICE_YEELIGHT: ("yeelight", None), SERVICE_YEELIGHT: ("yeelight", None),
"yamaha": ("media_player", "yamaha"), "yamaha": ("media_player", "yamaha"),
"logitech_mediaserver": ("media_player", "squeezebox"),
"frontier_silicon": ("media_player", "frontier_silicon"), "frontier_silicon": ("media_player", "frontier_silicon"),
"openhome": ("media_player", "openhome"), "openhome": ("media_player", "openhome"),
"bose_soundtouch": ("media_player", "soundtouch"), "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.""" """Constants for the Squeezebox component."""
from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING
DOMAIN = "squeezebox" DOMAIN = "squeezebox"
SERVICE_CALL_METHOD = "call_method" ENTRY_PLAYERS = "entry_players"
SQUEEZEBOX_MODE = { KNOWN_PLAYERS = "known_players"
"pause": STATE_PAUSED, PLAYER_DISCOVERY_UNSUB = "player_discovery_unsub"
"play": STATE_PLAYING, DEFAULT_PORT = 9000
"stop": STATE_IDLE,
}

View File

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

View File

@ -1,10 +1,11 @@
"""Support for interfacing to the Logitech SqueezeBox API.""" """Support for interfacing to the Logitech SqueezeBox API."""
import asyncio
import logging import logging
import socket
from pysqueezebox import Server from pysqueezebox import Server
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity
from homeassistant.components.media_player.const import ( from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_ENQUEUE,
@ -28,14 +29,25 @@ from homeassistant.const import (
CONF_PASSWORD, CONF_PASSWORD,
CONF_PORT, CONF_PORT,
CONF_USERNAME, CONF_USERNAME,
EVENT_HOMEASSISTANT_START,
STATE_IDLE,
STATE_OFF, 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 import config_validation as cv, entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util.dt import utcnow 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_METHOD = "call_method"
SERVICE_CALL_QUERY = "call_query" SERVICE_CALL_QUERY = "call_query"
@ -47,8 +59,7 @@ ATTR_SYNC_GROUP = "sync_group"
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DEFAULT_PORT = 9000 DISCOVERY_INTERVAL = 60
TIMEOUT = 10
SUPPORT_SQUEEZEBOX = ( SUPPORT_SQUEEZEBOX = (
SUPPORT_PAUSE SUPPORT_PAUSE
@ -65,21 +76,23 @@ SUPPORT_SQUEEZEBOX = (
| SUPPORT_CLEAR_PLAYLIST | SUPPORT_CLEAR_PLAYLIST
) )
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( 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.Required(CONF_HOST): cv.string,
vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_PASSWORD): cv.string,
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_USERNAME): cv.string,
} }
),
) )
DATA_SQUEEZEBOX = "squeezebox" KNOWN_SERVERS = "known_servers"
KNOWN_SERVERS = "squeezebox_known_servers"
ATTR_PARAMETERS = "parameters" ATTR_PARAMETERS = "parameters"
ATTR_OTHER_PLAYER = "other_player" ATTR_OTHER_PLAYER = "other_player"
ATTR_TO_PROPERTY = [ ATTR_TO_PROPERTY = [
@ -87,57 +100,84 @@ ATTR_TO_PROPERTY = [
ATTR_SYNC_GROUP, 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): 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 config:
if known_servers is None: await hass.config_entries.flow.async_init(
hass.data[KNOWN_SERVERS] = known_servers = set() 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) username = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD) password = config.get(CONF_PASSWORD)
host = config[CONF_HOST]
port = config[CONF_PORT]
if discovery_info is not None: hass.data.setdefault(DOMAIN, {})
host = discovery_info.get("host") hass.data[DOMAIN].setdefault(config_entry.entry_id, {})
port = discovery_info.get("port")
else:
host = config.get(CONF_HOST)
port = config.get(CONF_PORT)
# In case the port is not discovered known_players = hass.data[DOMAIN].get(KNOWN_PLAYERS)
if port is None: if known_players is None:
port = DEFAULT_PORT hass.data[DOMAIN][KNOWN_PLAYERS] = known_players = []
# Get IP of host, to prevent duplication of same host (different DNS names) entry_players = hass.data[DOMAIN][config_entry.entry_id].setdefault(
try: ENTRY_PLAYERS, []
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
if ipaddr in known_servers: _LOGGER.debug("Creating LMS object for %s", host)
return
_LOGGER.debug("Creating LMS object for %s", ipaddr)
lms = Server(async_get_clientsession(hass), host, port, username, password) lms = Server(async_get_clientsession(hass), host, port, username, password)
known_servers.add(ipaddr)
async def _discovery(now=None):
"""Discover squeezebox players by polling server."""
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() players = await lms.async_get_players()
if players is None: if players:
raise PlatformNotReady
media_players = []
for player in players: for player in players:
media_players.append(SqueezeBoxDevice(player)) hass.async_create_task(_discovered_player(player))
hass.data[DATA_SQUEEZEBOX].extend(media_players) hass.data[DOMAIN][config_entry.entry_id][
async_add_entities(media_players) 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 = entity_platform.current_platform.get()
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_CALL_METHOD, SERVICE_CALL_METHOD,
{ {
@ -148,7 +188,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
}, },
"async_call_method", "async_call_method",
) )
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_CALL_QUERY, SERVICE_CALL_QUERY,
{ {
@ -159,17 +198,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
}, },
"async_call_query", "async_call_query",
) )
platform.async_register_entity_service( platform.async_register_entity_service(
SERVICE_SYNC, {vol.Required(ATTR_OTHER_PLAYER): cv.string}, "async_sync", SERVICE_SYNC, {vol.Required(ATTR_OTHER_PLAYER): cv.string}, "async_sync",
) )
platform.async_register_entity_service(SERVICE_UNSYNC, None, "async_unsync") 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 return True
class SqueezeBoxDevice(MediaPlayerEntity): class SqueezeBoxEntity(MediaPlayerEntity):
""" """
Representation of a SqueezeBox device. Representation of a SqueezeBox device.
@ -181,6 +226,7 @@ class SqueezeBoxDevice(MediaPlayerEntity):
self._player = player self._player = player
self._last_update = None self._last_update = None
self._query_result = {} self._query_result = {}
self._available = True
@property @property
def device_state_attributes(self): def device_state_attributes(self):
@ -203,10 +249,22 @@ class SqueezeBoxDevice(MediaPlayerEntity):
"""Return a unique ID.""" """Return a unique ID."""
return self._player.player_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 @property
def state(self): def state(self):
"""Return the state of the device.""" """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 return STATE_OFF
if self._player.mode: if self._player.mode:
return SQUEEZEBOX_MODE.get(self._player.mode) return SQUEEZEBOX_MODE.get(self._player.mode)
@ -214,13 +272,15 @@ class SqueezeBoxDevice(MediaPlayerEntity):
async def async_update(self): async def async_update(self):
"""Update the Player() object.""" """Update the Player() object."""
# only update available players, newly available players will be rediscovered and marked available
if self._available:
last_media_position = self.media_position last_media_position = self.media_position
await self._player.async_update() await self._player.async_update()
if self.media_position != last_media_position: if self.media_position != last_media_position:
_LOGGER.debug(
"Media position updated for %s: %s", self, self.media_position
)
self._last_update = utcnow() self._last_update = utcnow()
if self._player.connected is False:
_LOGGER.info("Player %s is not available", self.name)
self._available = False
@property @property
def volume_level(self): def volume_level(self):
@ -291,7 +351,9 @@ class SqueezeBoxDevice(MediaPlayerEntity):
@property @property
def sync_group(self): def sync_group(self):
"""List players we are synced with.""" """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 = [] sync_group = []
for player in self._player.sync_group: for player in self._player.sync_group:
if player in player_ids: 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 If the other player is a member of a sync group, it will leave the current sync group
without asking. 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) other_player_id = player_ids.get(other_player)
if other_player_id: if other_player_id:
await self._player.async_sync(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", "sonos",
"speedtestdotnet", "speedtestdotnet",
"spotify", "spotify",
"squeezebox",
"starline", "starline",
"synology_dsm", "synology_dsm",
"tado", "tado",

View File

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

View File

@ -729,6 +729,9 @@ pysonos==0.0.31
# homeassistant.components.spc # homeassistant.components.spc
pyspcwebgw==0.4.0 pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
pysqueezebox==0.2.4
# homeassistant.components.ecobee # homeassistant.components.ecobee
python-ecobee-api==0.2.5 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"