mirror of
https://github.com/home-assistant/core.git
synced 2025-04-24 01:08:12 +00:00
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:
parent
e25f216fd6
commit
3f427602ba
@ -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
|
||||
|
@ -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"),
|
||||
|
@ -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)
|
||||
|
189
homeassistant/components/squeezebox/config_flow.py
Normal file
189
homeassistant/components/squeezebox/config_flow.py
Normal 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()
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
33
homeassistant/components/squeezebox/strings.json
Normal file
33
homeassistant/components/squeezebox/strings.json
Normal 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."
|
||||
}
|
||||
}
|
||||
}
|
33
homeassistant/components/squeezebox/translations/en.json
Normal file
33
homeassistant/components/squeezebox/translations/en.json
Normal 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"
|
||||
}
|
@ -148,6 +148,7 @@ FLOWS = [
|
||||
"sonos",
|
||||
"speedtestdotnet",
|
||||
"spotify",
|
||||
"squeezebox",
|
||||
"starline",
|
||||
"synology_dsm",
|
||||
"tado",
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
1
tests/components/squeezebox/__init__.py
Normal file
1
tests/components/squeezebox/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Tests for the Logitech Squeezebox integration."""
|
263
tests/components/squeezebox/test_config_flow.py
Normal file
263
tests/components/squeezebox/test_config_flow.py
Normal 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"
|
Loading…
x
Reference in New Issue
Block a user