diff --git a/.coveragerc b/.coveragerc index 87f3eeabcac..a1c3307c12c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -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 diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 0144b3b8280..1c2f816ad40 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.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"), diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py index 5250a6dc267..ff365640e9a 100644 --- a/homeassistant/components/squeezebox/__init__.py +++ b/homeassistant/components/squeezebox/__init__.py @@ -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) diff --git a/homeassistant/components/squeezebox/config_flow.py b/homeassistant/components/squeezebox/config_flow.py new file mode 100644 index 00000000000..bab53d7426b --- /dev/null +++ b/homeassistant/components/squeezebox/config_flow.py @@ -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() diff --git a/homeassistant/components/squeezebox/const.py b/homeassistant/components/squeezebox/const.py index e7e52fe2d80..8d716ffedc1 100644 --- a/homeassistant/components/squeezebox/const.py +++ b/homeassistant/components/squeezebox/const.py @@ -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 diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index 98456de67b5..b682887779b 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -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 } diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index 7194959d990..1a739e6972e 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -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) diff --git a/homeassistant/components/squeezebox/strings.json b/homeassistant/components/squeezebox/strings.json new file mode 100644 index 00000000000..d905335a6ae --- /dev/null +++ b/homeassistant/components/squeezebox/strings.json @@ -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." + } + } +} diff --git a/homeassistant/components/squeezebox/translations/en.json b/homeassistant/components/squeezebox/translations/en.json new file mode 100644 index 00000000000..82b03189788 --- /dev/null +++ b/homeassistant/components/squeezebox/translations/en.json @@ -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" +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 29f2883cf19..cf950edb901 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -148,6 +148,7 @@ FLOWS = [ "sonos", "speedtestdotnet", "spotify", + "squeezebox", "starline", "synology_dsm", "tado", diff --git a/requirements_all.txt b/requirements_all.txt index 33ac932e565..04f9da0a2a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c22db62b9ee..5742fa58c8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/squeezebox/__init__.py b/tests/components/squeezebox/__init__.py new file mode 100644 index 00000000000..34c0363292d --- /dev/null +++ b/tests/components/squeezebox/__init__.py @@ -0,0 +1 @@ +"""Tests for the Logitech Squeezebox integration.""" diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py new file mode 100644 index 00000000000..ec5a649fdc3 --- /dev/null +++ b/tests/components/squeezebox/test_config_flow.py @@ -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"