Add Sonos discovery of multiple households (#21337)

* Remove confusing device naming

* Add discovery of multiple households

* Rename SonosDevice to SonosEntity
This commit is contained in:
Anders Melchiorsen 2019-02-24 18:45:08 +01:00 committed by GitHub
parent 47220d71a1
commit a4bb35142c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 89 additions and 88 deletions

View file

@ -4,7 +4,7 @@ from homeassistant.helpers import config_entry_flow
DOMAIN = 'sonos' DOMAIN = 'sonos'
REQUIREMENTS = ['pysonos==0.0.6'] REQUIREMENTS = ['pysonos==0.0.7']
async def async_setup(hass, config): async def async_setup(hass, config):

View file

@ -48,7 +48,7 @@ SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer'
SERVICE_UPDATE_ALARM = 'sonos_update_alarm' SERVICE_UPDATE_ALARM = 'sonos_update_alarm'
SERVICE_SET_OPTION = 'sonos_set_option' SERVICE_SET_OPTION = 'sonos_set_option'
DATA_SONOS = 'sonos_devices' DATA_SONOS = 'sonos_media_player'
SOURCE_LINEIN = 'Line-in' SOURCE_LINEIN = 'Line-in'
SOURCE_TV = 'TV' SOURCE_TV = 'TV'
@ -114,7 +114,7 @@ class SonosData:
def __init__(self): def __init__(self):
"""Initialize the data.""" """Initialize the data."""
self.uids = set() self.uids = set()
self.devices = [] self.entities = []
self.topology_lock = threading.Lock() self.topology_lock = threading.Lock()
@ -129,9 +129,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
async def async_setup_entry(hass, config_entry, async_add_entities): async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up Sonos from a config entry.""" """Set up Sonos from a config entry."""
def add_entities(devices, update_before_add=False): def add_entities(entities, update_before_add=False):
"""Sync version of async add devices.""" """Sync version of async add entities."""
hass.add_job(async_add_entities, devices, update_before_add) hass.add_job(async_add_entities, entities, update_before_add)
hass.async_add_executor_job( hass.async_add_executor_job(
_setup_platform, hass, hass.data[SONOS_DOMAIN].get('media_player', {}), _setup_platform, hass, hass.data[SONOS_DOMAIN].get('media_player', {}),
@ -153,7 +153,7 @@ def _setup_platform(hass, config, add_entities, discovery_info):
if discovery_info: if discovery_info:
player = pysonos.SoCo(discovery_info.get('host')) player = pysonos.SoCo(discovery_info.get('host'))
# If device already exists by config # If host already exists by config
if player.uid in hass.data[DATA_SONOS].uids: if player.uid in hass.data[DATA_SONOS].uids:
return return
@ -176,53 +176,54 @@ def _setup_platform(hass, config, add_entities, discovery_info):
_LOGGER.warning("Failed to initialize '%s'", host) _LOGGER.warning("Failed to initialize '%s'", host)
else: else:
players = pysonos.discover( players = pysonos.discover(
interface_addr=config.get(CONF_INTERFACE_ADDR)) interface_addr=config.get(CONF_INTERFACE_ADDR),
all_households=True)
if not players: if not players:
_LOGGER.warning("No Sonos speakers found") _LOGGER.warning("No Sonos speakers found")
return return
hass.data[DATA_SONOS].uids.update(p.uid for p in players) hass.data[DATA_SONOS].uids.update(p.uid for p in players)
add_entities(SonosDevice(p) for p in players) add_entities(SonosEntity(p) for p in players)
_LOGGER.debug("Added %s Sonos speakers", len(players)) _LOGGER.debug("Added %s Sonos speakers", len(players))
def service_handle(service): def service_handle(service):
"""Handle for services.""" """Handle for services."""
entity_ids = service.data.get('entity_id') entity_ids = service.data.get('entity_id')
devices = hass.data[DATA_SONOS].devices entities = hass.data[DATA_SONOS].entities
if entity_ids: if entity_ids:
devices = [d for d in devices if d.entity_id in entity_ids] entities = [e for e in entities if e.entity_id in entity_ids]
if service.service == SERVICE_JOIN: if service.service == SERVICE_JOIN:
master = [device for device in hass.data[DATA_SONOS].devices master = [e for e in hass.data[DATA_SONOS].entities
if device.entity_id == service.data[ATTR_MASTER]] if e.entity_id == service.data[ATTR_MASTER]]
if master: if master:
with hass.data[DATA_SONOS].topology_lock: with hass.data[DATA_SONOS].topology_lock:
master[0].join(devices) master[0].join(entities)
return return
if service.service == SERVICE_UNJOIN: if service.service == SERVICE_UNJOIN:
with hass.data[DATA_SONOS].topology_lock: with hass.data[DATA_SONOS].topology_lock:
for device in devices: for entity in entities:
device.unjoin() entity.unjoin()
return return
for device in devices: for entity in entities:
if service.service == SERVICE_SNAPSHOT: if service.service == SERVICE_SNAPSHOT:
device.snapshot(service.data[ATTR_WITH_GROUP]) entity.snapshot(service.data[ATTR_WITH_GROUP])
elif service.service == SERVICE_RESTORE: elif service.service == SERVICE_RESTORE:
device.restore(service.data[ATTR_WITH_GROUP]) entity.restore(service.data[ATTR_WITH_GROUP])
elif service.service == SERVICE_SET_TIMER: elif service.service == SERVICE_SET_TIMER:
device.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME])
elif service.service == SERVICE_CLEAR_TIMER: elif service.service == SERVICE_CLEAR_TIMER:
device.clear_sleep_timer() entity.clear_sleep_timer()
elif service.service == SERVICE_UPDATE_ALARM: elif service.service == SERVICE_UPDATE_ALARM:
device.set_alarm(**service.data) entity.set_alarm(**service.data)
elif service.service == SERVICE_SET_OPTION: elif service.service == SERVICE_SET_OPTION:
device.set_option(**service.data) entity.set_option(**service.data)
device.schedule_update_ha_state(True) entity.schedule_update_ha_state(True)
hass.services.register( hass.services.register(
DOMAIN, SERVICE_JOIN, service_handle, DOMAIN, SERVICE_JOIN, service_handle,
@ -270,9 +271,9 @@ class _ProcessSonosEventQueue:
def _get_entity_from_soco_uid(hass, uid): def _get_entity_from_soco_uid(hass, uid):
"""Return SonosDevice from SoCo uid.""" """Return SonosEntity from SoCo uid."""
for entity in hass.data[DATA_SONOS].devices: for entity in hass.data[DATA_SONOS].entities:
if uid == entity.soco.uid: if uid == entity.unique_id:
return entity return entity
return None return None
@ -303,11 +304,11 @@ def soco_error(errorcodes=None):
def soco_coordinator(funct): def soco_coordinator(funct):
"""Call function on coordinator.""" """Call function on coordinator."""
@ft.wraps(funct) @ft.wraps(funct)
def wrapper(device, *args, **kwargs): def wrapper(entity, *args, **kwargs):
"""Wrap for call to coordinator.""" """Wrap for call to coordinator."""
if device.is_coordinator: if entity.is_coordinator:
return funct(device, *args, **kwargs) return funct(entity, *args, **kwargs)
return funct(device.coordinator, *args, **kwargs) return funct(entity.coordinator, *args, **kwargs)
return wrapper return wrapper
@ -329,11 +330,11 @@ def _is_radio_uri(uri):
return uri.startswith(radio_schemes) return uri.startswith(radio_schemes)
class SonosDevice(MediaPlayerDevice): class SonosEntity(MediaPlayerDevice):
"""Representation of a Sonos device.""" """Representation of a Sonos entity."""
def __init__(self, player): def __init__(self, player):
"""Initialize the Sonos device.""" """Initialize the Sonos entity."""
self._subscriptions = [] self._subscriptions = []
self._receives_events = False self._receives_events = False
self._volume_increment = 2 self._volume_increment = 2
@ -366,7 +367,7 @@ class SonosDevice(MediaPlayerDevice):
async def async_added_to_hass(self): async def async_added_to_hass(self):
"""Subscribe sonos events.""" """Subscribe sonos events."""
self.hass.data[DATA_SONOS].devices.append(self) self.hass.data[DATA_SONOS].entities.append(self)
self.hass.async_add_executor_job(self._subscribe_to_player_events) self.hass.async_add_executor_job(self._subscribe_to_player_events)
@property @property
@ -376,7 +377,7 @@ class SonosDevice(MediaPlayerDevice):
@property @property
def name(self): def name(self):
"""Return the name of the device.""" """Return the name of the entity."""
return self._name return self._name
@property @property
@ -394,7 +395,7 @@ class SonosDevice(MediaPlayerDevice):
@property @property
@soco_coordinator @soco_coordinator
def state(self): def state(self):
"""Return the state of the device.""" """Return the state of the entity."""
if self._status in ('PAUSED_PLAYBACK', 'STOPPED'): if self._status in ('PAUSED_PLAYBACK', 'STOPPED'):
return STATE_PAUSED return STATE_PAUSED
if self._status in ('PLAYING', 'TRANSITIONING'): if self._status in ('PLAYING', 'TRANSITIONING'):
@ -410,7 +411,7 @@ class SonosDevice(MediaPlayerDevice):
@property @property
def soco(self): def soco(self):
"""Return soco device.""" """Return soco object."""
return self._player return self._player
@property @property
@ -434,7 +435,7 @@ class SonosDevice(MediaPlayerDevice):
return False return False
def _set_basic_information(self): def _set_basic_information(self):
"""Set initial device information.""" """Set initial entity information."""
speaker_info = self.soco.get_speaker_info(True) speaker_info = self.soco.get_speaker_info(True)
self._name = speaker_info['zone_name'] self._name = speaker_info['zone_name']
self._model = speaker_info['model_name'] self._model = speaker_info['model_name']
@ -477,8 +478,8 @@ class SonosDevice(MediaPlayerDevice):
self._receives_events = False self._receives_events = False
# New player available, build the current group topology # New player available, build the current group topology
for device in self.hass.data[DATA_SONOS].devices: for entity in self.hass.data[DATA_SONOS].entities:
device.update_groups() entity.update_groups()
player = self.soco player = self.soco
@ -554,7 +555,7 @@ class SonosDevice(MediaPlayerDevice):
self.schedule_update_ha_state() self.schedule_update_ha_state()
# Also update slaves # Also update slaves
for entity in self.hass.data[DATA_SONOS].devices: for entity in self.hass.data[DATA_SONOS].entities:
coordinator = entity.coordinator coordinator = entity.coordinator
if coordinator and coordinator.unique_id == self.unique_id: if coordinator and coordinator.unique_id == self.unique_id:
entity.schedule_update_ha_state() entity.schedule_update_ha_state()
@ -1087,7 +1088,7 @@ class SonosDevice(MediaPlayerDevice):
@property @property
def device_state_attributes(self): def device_state_attributes(self):
"""Return device specific state attributes.""" """Return entity specific state attributes."""
attributes = {ATTR_SONOS_GROUP: self._sonos_group} attributes = {ATTR_SONOS_GROUP: self._sonos_group}
if self._night_sound is not None: if self._night_sound is not None:

View file

@ -1263,7 +1263,7 @@ pysmartthings==0.6.3
pysnmp==4.4.8 pysnmp==4.4.8
# homeassistant.components.sonos # homeassistant.components.sonos
pysonos==0.0.6 pysonos==0.0.7
# homeassistant.components.spc # homeassistant.components.spc
pyspcwebgw==0.4.0 pyspcwebgw==0.4.0

View file

@ -226,7 +226,7 @@ pysmartapp==0.3.0
pysmartthings==0.6.3 pysmartthings==0.6.3
# homeassistant.components.sonos # homeassistant.components.sonos
pysonos==0.0.6 pysonos==0.0.7
# homeassistant.components.spc # homeassistant.components.spc
pyspcwebgw==0.4.0 pyspcwebgw==0.4.0

View file

@ -21,7 +21,7 @@ ENTITY_ID = 'media_player.kitchen'
class pysonosDiscoverMock(): class pysonosDiscoverMock():
"""Mock class for the pysonos.discover method.""" """Mock class for the pysonos.discover method."""
def discover(interface_addr): def discover(interface_addr, all_households=False):
"""Return tuple of pysonos.SoCo objects representing found speakers.""" """Return tuple of pysonos.SoCo objects representing found speakers."""
return {SoCoMock('192.0.2.1')} return {SoCoMock('192.0.2.1')}
@ -123,10 +123,10 @@ class SoCoMock():
def add_entities_factory(hass): def add_entities_factory(hass):
"""Add devices factory.""" """Add entities factory."""
def add_entities(devices, update_befor_add=False): def add_entities(entities, update_befor_add=False):
"""Fake add device.""" """Fake add entity."""
hass.data[sonos.DATA_SONOS].devices = devices hass.data[sonos.DATA_SONOS].entities = entities
return add_entities return add_entities
@ -144,14 +144,14 @@ class TestSonosMediaPlayer(unittest.TestCase):
return True return True
# Monkey patches # Monkey patches
self.real_available = sonos.SonosDevice.available self.real_available = sonos.SonosEntity.available
sonos.SonosDevice.available = monkey_available sonos.SonosEntity.available = monkey_available
# pylint: disable=invalid-name # pylint: disable=invalid-name
def tearDown(self): def tearDown(self):
"""Stop everything that was started.""" """Stop everything that was started."""
# Monkey patches # Monkey patches
sonos.SonosDevice.available = self.real_available sonos.SonosEntity.available = self.real_available
self.hass.stop() self.hass.stop()
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@ -162,9 +162,9 @@ class TestSonosMediaPlayer(unittest.TestCase):
'host': '192.0.2.1' 'host': '192.0.2.1'
}) })
devices = list(self.hass.data[sonos.DATA_SONOS].devices) entities = list(self.hass.data[sonos.DATA_SONOS].entities)
assert len(devices) == 1 assert len(entities) == 1
assert devices[0].name == 'Kitchen' assert entities[0].name == 'Kitchen'
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch('socket.create_connection', side_effect=socket.error())
@ -182,7 +182,7 @@ class TestSonosMediaPlayer(unittest.TestCase):
assert setup_component(self.hass, DOMAIN, config) assert setup_component(self.hass, DOMAIN, config)
assert len(self.hass.data[sonos.DATA_SONOS].devices) == 1 assert len(self.hass.data[sonos.DATA_SONOS].entities) == 1
assert discover_mock.call_count == 1 assert discover_mock.call_count == 1
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@ -198,9 +198,9 @@ class TestSonosMediaPlayer(unittest.TestCase):
assert setup_component(self.hass, DOMAIN, config) assert setup_component(self.hass, DOMAIN, config)
devices = self.hass.data[sonos.DATA_SONOS].devices entities = self.hass.data[sonos.DATA_SONOS].entities
assert len(devices) == 1 assert len(entities) == 1
assert devices[0].name == 'Kitchen' assert entities[0].name == 'Kitchen'
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch('socket.create_connection', side_effect=socket.error())
@ -215,9 +215,9 @@ class TestSonosMediaPlayer(unittest.TestCase):
assert setup_component(self.hass, DOMAIN, config) assert setup_component(self.hass, DOMAIN, config)
devices = self.hass.data[sonos.DATA_SONOS].devices entities = self.hass.data[sonos.DATA_SONOS].entities
assert len(devices) == 2 assert len(entities) == 2
assert devices[0].name == 'Kitchen' assert entities[0].name == 'Kitchen'
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch('socket.create_connection', side_effect=socket.error())
@ -232,9 +232,9 @@ class TestSonosMediaPlayer(unittest.TestCase):
assert setup_component(self.hass, DOMAIN, config) assert setup_component(self.hass, DOMAIN, config)
devices = self.hass.data[sonos.DATA_SONOS].devices entities = self.hass.data[sonos.DATA_SONOS].entities
assert len(devices) == 2 assert len(entities) == 2
assert devices[0].name == 'Kitchen' assert entities[0].name == 'Kitchen'
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@mock.patch.object(pysonos, 'discover', new=pysonosDiscoverMock.discover) @mock.patch.object(pysonos, 'discover', new=pysonosDiscoverMock.discover)
@ -242,9 +242,9 @@ class TestSonosMediaPlayer(unittest.TestCase):
def test_ensure_setup_sonos_discovery(self, *args): def test_ensure_setup_sonos_discovery(self, *args):
"""Test a single device using the autodiscovery provided by Sonos.""" """Test a single device using the autodiscovery provided by Sonos."""
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass)) sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass))
devices = list(self.hass.data[sonos.DATA_SONOS].devices) entities = list(self.hass.data[sonos.DATA_SONOS].entities)
assert len(devices) == 1 assert len(entities) == 1
assert devices[0].name == 'Kitchen' assert entities[0].name == 'Kitchen'
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@mock.patch('socket.create_connection', side_effect=socket.error()) @mock.patch('socket.create_connection', side_effect=socket.error())
@ -254,10 +254,10 @@ class TestSonosMediaPlayer(unittest.TestCase):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1' 'host': '192.0.2.1'
}) })
device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
device.hass = self.hass entity.hass = self.hass
device.set_sleep_timer(30) entity.set_sleep_timer(30)
set_sleep_timerMock.assert_called_once_with(30) set_sleep_timerMock.assert_called_once_with(30)
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@ -268,10 +268,10 @@ class TestSonosMediaPlayer(unittest.TestCase):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1' 'host': '192.0.2.1'
}) })
device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
device.hass = self.hass entity.hass = self.hass
device.set_sleep_timer(None) entity.set_sleep_timer(None)
set_sleep_timerMock.assert_called_once_with(None) set_sleep_timerMock.assert_called_once_with(None)
@mock.patch('pysonos.SoCo', new=SoCoMock) @mock.patch('pysonos.SoCo', new=SoCoMock)
@ -282,8 +282,8 @@ class TestSonosMediaPlayer(unittest.TestCase):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1' 'host': '192.0.2.1'
}) })
device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
device.hass = self.hass entity.hass = self.hass
alarm1 = alarms.Alarm(pysonos_mock) alarm1 = alarms.Alarm(pysonos_mock)
alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False, alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False,
include_linked_zones=False, volume=100) include_linked_zones=False, volume=100)
@ -294,9 +294,9 @@ class TestSonosMediaPlayer(unittest.TestCase):
'include_linked_zones': True, 'include_linked_zones': True,
'volume': 0.30, 'volume': 0.30,
} }
device.set_alarm(alarm_id=2) entity.set_alarm(alarm_id=2)
alarm1.save.assert_not_called() alarm1.save.assert_not_called()
device.set_alarm(alarm_id=1, **attrs) entity.set_alarm(alarm_id=1, **attrs)
assert alarm1.enabled == attrs['enabled'] assert alarm1.enabled == attrs['enabled']
assert alarm1.start_time == attrs['time'] assert alarm1.start_time == attrs['time']
assert alarm1.include_linked_zones == \ assert alarm1.include_linked_zones == \
@ -312,11 +312,11 @@ class TestSonosMediaPlayer(unittest.TestCase):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1' 'host': '192.0.2.1'
}) })
device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
device.hass = self.hass entity.hass = self.hass
snapshotMock.return_value = True snapshotMock.return_value = True
device.snapshot() entity.snapshot()
assert snapshotMock.call_count == 1 assert snapshotMock.call_count == 1
assert snapshotMock.call_args == mock.call() assert snapshotMock.call_args == mock.call()
@ -330,13 +330,13 @@ class TestSonosMediaPlayer(unittest.TestCase):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), { sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1' 'host': '192.0.2.1'
}) })
device = list(self.hass.data[sonos.DATA_SONOS].devices)[-1] entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
device.hass = self.hass entity.hass = self.hass
restoreMock.return_value = True restoreMock.return_value = True
device._snapshot_coordinator = mock.MagicMock() entity._snapshot_coordinator = mock.MagicMock()
device._snapshot_coordinator.soco_device = SoCoMock('192.0.2.17') entity._snapshot_coordinator.soco_entity = SoCoMock('192.0.2.17')
device._soco_snapshot = Snapshot(device._player) entity._soco_snapshot = Snapshot(entity._player)
device.restore() entity.restore()
assert restoreMock.call_count == 1 assert restoreMock.call_count == 1
assert restoreMock.call_args == mock.call(False) assert restoreMock.call_args == mock.call(False)