From c5e5787e1d72334afd98d33c104b1dc0000133e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 May 2021 08:31:22 -0500 Subject: [PATCH] Replace sonos discovery thread with ssdp callback registration (#51033) Co-authored-by: jjlawren --- homeassistant/components/sonos/__init__.py | 157 +++++++++++-------- homeassistant/components/sonos/const.py | 2 + homeassistant/components/sonos/manifest.json | 1 + tests/components/sonos/conftest.py | 15 +- 4 files changed, 108 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index a904ae58db6..3b158a7db81 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -6,6 +6,7 @@ from collections import OrderedDict import datetime import logging import socket +from urllib.parse import urlparse import pysonos from pysonos import events_asyncio @@ -15,6 +16,7 @@ from pysonos.exceptions import SoCoException import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -24,7 +26,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( DATA_SONOS, @@ -34,6 +36,7 @@ from .const import ( SONOS_ALARM_UPDATE, SONOS_GROUP_UPDATE, SONOS_SEEN, + UPNP_ST, ) from .favorites import SonosFavorites from .speaker import SonosSpeaker @@ -74,7 +77,6 @@ class SonosData: self.favorites: dict[str, SonosFavorites] = {} self.alarms: dict[str, Alarm] = {} self.topology_condition = asyncio.Condition() - self.discovery_thread = None self.hosts_heartbeat = None @@ -94,89 +96,101 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Set up Sonos from a config entry.""" pysonos.config.EVENTS_MODULE = events_asyncio if DATA_SONOS not in hass.data: hass.data[DATA_SONOS] = SonosData() + data = hass.data[DATA_SONOS] config = hass.data[DOMAIN].get("media_player", {}) + hosts = config.get(CONF_HOSTS, []) + discovery_lock = asyncio.Lock() _LOGGER.debug("Reached async_setup_entry, config=%s", config) advertise_addr = config.get(CONF_ADVERTISE_ADDR) if advertise_addr: pysonos.config.EVENT_ADVERTISE_IP = advertise_addr - def _stop_discovery(event: Event) -> None: - data = hass.data[DATA_SONOS] - if data.discovery_thread: - data.discovery_thread.stop() - data.discovery_thread = None + async def _async_stop_event_listener(event: Event) -> None: + if events_asyncio.event_listener: + await events_asyncio.event_listener.async_stop() + + def _stop_manual_heartbeat(event: Event) -> None: if data.hosts_heartbeat: data.hosts_heartbeat() data.hosts_heartbeat = None - def _discovery(now: datetime.datetime | None = None) -> None: - """Discover players from network or configuration.""" - hosts = config.get(CONF_HOSTS) + def _discovered_player(soco: SoCo) -> None: + """Handle a (re)discovered player.""" + try: + _LOGGER.debug("Reached _discovered_player, soco=%s", soco) + speaker_info = soco.get_speaker_info(True) + _LOGGER.debug("Adding new speaker: %s", speaker_info) + speaker = SonosSpeaker(hass, soco, speaker_info) + data.discovered[soco.uid] = speaker + if soco.household_id not in data.favorites: + data.favorites[soco.household_id] = SonosFavorites( + hass, soco.household_id + ) + data.favorites[soco.household_id].update() + speaker.setup() + except SoCoException as ex: + _LOGGER.debug("SoCoException, ex=%s", ex) - def _discovered_player(soco: SoCo) -> None: - """Handle a (re)discovered player.""" + def _manual_hosts(now: datetime.datetime | None = None) -> None: + """Players from network configuration.""" + for host in hosts: try: - _LOGGER.debug("Reached _discovered_player, soco=%s", soco) + _LOGGER.debug("Testing %s", host) + player = pysonos.SoCo(socket.gethostbyname(host)) + if player.is_visible: + # Make sure that the player is available + _ = player.volume + _discovered_player(player) + except (OSError, SoCoException) as ex: + _LOGGER.debug("Issue connecting to '%s': %s", host, ex) + if now is None: + _LOGGER.warning("Failed to initialize '%s'", host) - data = hass.data[DATA_SONOS] - - if soco.uid not in data.discovered: - speaker_info = soco.get_speaker_info(True) - _LOGGER.debug("Adding new speaker: %s", speaker_info) - speaker = SonosSpeaker(hass, soco, speaker_info) - data.discovered[soco.uid] = speaker - if soco.household_id not in data.favorites: - data.favorites[soco.household_id] = SonosFavorites( - hass, soco.household_id - ) - data.favorites[soco.household_id].update() - speaker.setup() - else: - dispatcher_send(hass, f"{SONOS_SEEN}-{soco.uid}", soco) - - except SoCoException as ex: - _LOGGER.debug("SoCoException, ex=%s", ex) - - if hosts: - for host in hosts: - try: - _LOGGER.debug("Testing %s", host) - player = pysonos.SoCo(socket.gethostbyname(host)) - if player.is_visible: - # Make sure that the player is available - _ = player.volume - - _discovered_player(player) - except (OSError, SoCoException) as ex: - _LOGGER.debug("Exception %s", ex) - if now is None: - _LOGGER.warning("Failed to initialize '%s'", host) - - _LOGGER.debug("Tested all hosts") - hass.data[DATA_SONOS].hosts_heartbeat = hass.helpers.event.call_later( - DISCOVERY_INTERVAL.total_seconds(), _discovery - ) - else: - _LOGGER.debug("Starting discovery thread") - hass.data[DATA_SONOS].discovery_thread = pysonos.discover_thread( - _discovered_player, - interval=DISCOVERY_INTERVAL.total_seconds(), - interface_addr=config.get(CONF_INTERFACE_ADDR), - ) - hass.data[DATA_SONOS].discovery_thread.name = "Sonos-Discovery" + _LOGGER.debug("Tested all hosts") + data.hosts_heartbeat = hass.helpers.event.call_later( + DISCOVERY_INTERVAL.total_seconds(), _manual_hosts + ) @callback def _async_signal_update_groups(event): async_dispatcher_send(hass, SONOS_GROUP_UPDATE) + def _discovered_ip(ip_address): + try: + player = pysonos.SoCo(ip_address) + except (OSError, SoCoException): + _LOGGER.debug("Failed to connect to discovered player '%s'", ip_address) + return + if player.is_visible: + _discovered_player(player) + + async def _async_create_discovered_player(uid, discovered_ip): + """Only create one player at a time.""" + async with discovery_lock: + if uid in data.discovered: + async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}") + return + await hass.async_add_executor_job(_discovered_ip, discovered_ip) + + @callback + def _async_discovered_player(info): + _LOGGER.debug("Sonos Discovery: %s", info) + uid = info.get(ssdp.ATTR_UPNP_UDN) + if uid.startswith("uuid:"): + uid = uid[5:] + discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname + asyncio.create_task(_async_create_discovered_player(uid, discovered_ip)) + @callback def _async_signal_update_alarms(event): async_dispatcher_send(hass, SONOS_ALARM_UPDATE) @@ -188,9 +202,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for platform in PLATFORMS ] ) - entry.async_on_unload( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_discovery) - ) entry.async_on_unload( hass.bus.async_listen_once( EVENT_HOMEASSISTANT_START, _async_signal_update_groups @@ -201,8 +212,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: EVENT_HOMEASSISTANT_START, _async_signal_update_alarms ) ) + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener + ) + ) _LOGGER.debug("Adding discovery job") - await hass.async_add_executor_job(_discovery) + if hosts: + entry.async_on_unload( + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _stop_manual_heartbeat + ) + ) + await hass.async_add_executor_job(_manual_hosts) + return + + entry.async_on_unload( + ssdp.async_register_callback( + hass, _async_discovered_player, {"st": UPNP_ST} + ) + ) hass.async_create_task(setup_platforms_and_discovery()) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 0a70844e6b5..d32dda6a53b 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -22,6 +22,8 @@ from homeassistant.components.media_player.const import ( from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1" + DOMAIN = "sonos" DATA_SONOS = "sonos_media_player" PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN} diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index e42937d3889..bb949fea8c1 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", "requirements": ["pysonos==0.0.50"], + "dependencies": ["ssdp"], "after_dependencies": ["plex"], "ssdp": [ { diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index fc5ef84c2d6..81ad0e6c8ef 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch as patch import pytest +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sonos import DOMAIN from homeassistant.const import CONF_HOSTS @@ -44,6 +45,7 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): "socket.gethostbyname", return_value="192.168.42.2" ): mock_soco = mock.return_value + mock_soco.ip_address = "192.168.42.2" mock_soco.uid = "RINCON_test" mock_soco.play_mode = "NORMAL" mock_soco.music_library = music_library @@ -67,11 +69,18 @@ def soco_fixture(music_library, speaker_info, battery_info, alarm_clock): def discover_fixture(soco): """Create a mock pysonos discover fixture.""" - def do_callback(callback, **kwargs): - callback(soco) + def do_callback(hass, callback, *args, **kwargs): + callback( + { + ssdp.ATTR_UPNP_UDN: soco.uid, + ssdp.ATTR_SSDP_LOCATION: f"http://{soco.ip_address}/", + } + ) return MagicMock() - with patch("pysonos.discover_thread", side_effect=do_callback) as mock: + with patch( + "homeassistant.components.ssdp.async_register_callback", side_effect=do_callback + ) as mock: yield mock