From 0ecf1521531b8d6fd9b98c75a392b43ee1789082 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 29 Apr 2019 10:20:09 +0200 Subject: [PATCH] Continuous discovery of Sonos speakers (#23484) --- .../components/sonos/media_player.py | 63 +++++++++++-------- tests/components/sonos/conftest.py | 6 +- tests/components/sonos/test_media_player.py | 8 ++- 3 files changed, 47 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 68bd81a6dc7..4aea88c6657 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -4,6 +4,7 @@ import datetime import functools as ft import logging import socket +import time import urllib import async_timeout @@ -35,6 +36,8 @@ _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +DISCOVERY_INTERVAL = 60 + # Quiet down pysonos logging to just actual problems. logging.getLogger('pysonos').setLevel(logging.WARNING) logging.getLogger('pysonos.data_structures_entry').setLevel(logging.ERROR) @@ -109,7 +112,6 @@ class SonosData: def __init__(self, hass): """Initialize the data.""" - self.uids = set() self.entities = [] self.topology_condition = asyncio.Condition(loop=hass.loop) @@ -134,32 +136,41 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if advertise_addr: pysonos.config.EVENT_ADVERTISE_IP = advertise_addr - def _create_sonos_entities(): - """Discover players and return a list of SonosEntity objects.""" - players = [] + def _discovery(now=None): + """Discover players from network or configuration.""" hosts = config.get(CONF_HOSTS) + def _discovered_player(soco): + """Handle a (re)discovered player.""" + try: + # Make sure that the player is available + _ = soco.volume + + entity = _get_entity_from_soco_uid(hass, soco.uid) + if not entity: + hass.add_job(async_add_entities, [SonosEntity(soco)]) + else: + entity.seen() + except SoCoException: + pass + if hosts: for host in hosts: try: - players.append(pysonos.SoCo(socket.gethostbyname(host))) + player = pysonos.SoCo(socket.gethostbyname(host)) + if player.is_visible: + _discovered_player(player) except (OSError, SoCoException): - _LOGGER.warning("Failed to initialize '%s'", host) + if now is None: + _LOGGER.warning("Failed to initialize '%s'", host) else: - players = pysonos.discover( - interface_addr=config.get(CONF_INTERFACE_ADDR), - all_households=True) + pysonos.discover_thread( + _discovered_player, + interface_addr=config.get(CONF_INTERFACE_ADDR)) - if not players: - _LOGGER.warning("No Sonos speakers found") + hass.helpers.event.call_later(DISCOVERY_INTERVAL, _discovery) - return [SonosEntity(p) for p in players] - - entities = await hass.async_add_executor_job(_create_sonos_entities) - hass.data[DATA_SONOS].uids.update(e.unique_id for e in entities) - - async_add_entities(entities) - _LOGGER.debug("Added %s Sonos speakers", len(entities)) + hass.async_add_executor_job(_discovery) def _service_to_entities(service): """Extract and return entities from service call.""" @@ -309,6 +320,7 @@ class SonosEntity(MediaPlayerDevice): def __init__(self, player): """Initialize the Sonos entity.""" + self._seen = None self._subscriptions = [] self._receives_events = False self._volume_increment = 2 @@ -338,6 +350,7 @@ class SonosEntity(MediaPlayerDevice): self._snapshot_group = None self._set_basic_information() + self.seen() async def async_added_to_hass(self): """Subscribe sonos events.""" @@ -397,20 +410,18 @@ class SonosEntity(MediaPlayerDevice): """Return coordinator of this player.""" return self._coordinator + def seen(self): + """Record that this player was seen right now.""" + self._seen = time.monotonic() + @property def available(self) -> bool: """Return True if entity is available.""" return self._available def _check_available(self): - """Check that we can still connect to the player.""" - try: - sock = socket.create_connection( - address=(self.soco.ip_address, 1443), timeout=3) - sock.close() - return True - except socket.error: - return False + """Check that we saw the player recently.""" + return self._seen > time.monotonic() - 2*DISCOVERY_INTERVAL def _set_basic_information(self): """Set initial entity information.""" diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 95bc66fe317..2f7faf03f4d 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -35,8 +35,10 @@ def soco_fixture(music_library, speaker_info, dummy_soco_service): @pytest.fixture(name="discover") def discover_fixture(soco): """Create a mock pysonos discover fixture.""" - with patch('pysonos.discover') as mock: - mock.return_value = {soco} + def do_callback(callback, **kwargs): + callback(soco) + + with patch('pysonos.discover_thread', side_effect=do_callback) as mock: yield mock diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index a06a6160400..f46fe41c36e 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -13,10 +13,14 @@ async def setup_platform(hass, config_entry, config): async def test_async_setup_entry_hosts(hass, config_entry, config, soco): """Test static setup.""" await setup_platform(hass, config_entry, config) - assert hass.data[media_player.DATA_SONOS].entities[0].soco == soco + + entity = hass.data[media_player.DATA_SONOS].entities[0] + assert entity.soco == soco async def test_async_setup_entry_discover(hass, config_entry, discover): """Test discovery setup.""" await setup_platform(hass, config_entry, {}) - assert hass.data[media_player.DATA_SONOS].uids == {'RINCON_test'} + + entity = hass.data[media_player.DATA_SONOS].entities[0] + assert entity.unique_id == 'RINCON_test'