Skip to content

Commit

Permalink
Fix Sonos snapshot/restore (home-assistant#21411)
Browse files Browse the repository at this point in the history
  • Loading branch information
amelchio authored and balloob committed Feb 25, 2019
1 parent 4e9d0eb commit 095a0d1
Show file tree
Hide file tree
Showing 2 changed files with 117 additions and 110 deletions.
186 changes: 90 additions & 96 deletions homeassistant/components/sonos/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,35 +195,30 @@ def service_handle(service):
if entity_ids:
entities = [e for e in entities if e.entity_id in entity_ids]

if service.service == SERVICE_JOIN:
master = [e for e in hass.data[DATA_SONOS].entities
if e.entity_id == service.data[ATTR_MASTER]]
if master:
with hass.data[DATA_SONOS].topology_lock:
master[0].join(entities)
return

if service.service == SERVICE_UNJOIN:
with hass.data[DATA_SONOS].topology_lock:
for entity in entities:
entity.unjoin()
return

for entity in entities:
with hass.data[DATA_SONOS].topology_lock:
if service.service == SERVICE_SNAPSHOT:
entity.snapshot(service.data[ATTR_WITH_GROUP])
snapshot(entities, service.data[ATTR_WITH_GROUP])
elif service.service == SERVICE_RESTORE:
entity.restore(service.data[ATTR_WITH_GROUP])
elif service.service == SERVICE_SET_TIMER:
entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME])
elif service.service == SERVICE_CLEAR_TIMER:
entity.clear_sleep_timer()
elif service.service == SERVICE_UPDATE_ALARM:
entity.set_alarm(**service.data)
elif service.service == SERVICE_SET_OPTION:
entity.set_option(**service.data)

entity.schedule_update_ha_state(True)
restore(entities, service.data[ATTR_WITH_GROUP])
elif service.service == SERVICE_JOIN:
master = [e for e in hass.data[DATA_SONOS].entities
if e.entity_id == service.data[ATTR_MASTER]]
if master:
master[0].join(entities)
else:
for entity in entities:
if service.service == SERVICE_UNJOIN:
entity.unjoin()
elif service.service == SERVICE_SET_TIMER:
entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME])
elif service.service == SERVICE_CLEAR_TIMER:
entity.clear_sleep_timer()
elif service.service == SERVICE_UPDATE_ALARM:
entity.set_alarm(**service.data)
elif service.service == SERVICE_SET_OPTION:
entity.set_option(**service.data)

entity.schedule_update_ha_state(True)

hass.services.register(
DOMAIN, SERVICE_JOIN, service_handle,
Expand Down Expand Up @@ -346,7 +341,7 @@ def __init__(self, player):
self._shuffle = None
self._name = None
self._coordinator = None
self._sonos_group = None
self._sonos_group = [self]
self._status = None
self._media_duration = None
self._media_position = None
Expand Down Expand Up @@ -375,6 +370,10 @@ def unique_id(self):
"""Return a unique ID."""
return self._unique_id

def __hash__(self):
"""Return a hash of self."""
return hash(self.unique_id)

@property
def name(self):
"""Return the name of the entity."""
Expand Down Expand Up @@ -729,7 +728,7 @@ def update_groups(self, event=None):
for uid in (coordinator_uid, *slave_uids):
entity = _get_entity_from_soco_uid(self.hass, uid)
if entity:
sonos_group.append(entity.entity_id)
sonos_group.append(entity)

self._coordinator = None
self._sonos_group = sonos_group
Expand Down Expand Up @@ -975,72 +974,6 @@ def unjoin(self):
self.soco.unjoin()
self._coordinator = None

@soco_error()
def snapshot(self, with_group=True):
"""Snapshot the player."""
from pysonos.snapshot import Snapshot

self._soco_snapshot = Snapshot(self.soco)
self._soco_snapshot.snapshot()

if with_group:
self._snapshot_group = self.soco.group
if self._coordinator:
self._coordinator.snapshot(False)
else:
self._snapshot_group = None

@soco_error()
def restore(self, with_group=True):
"""Restore snapshot for the player."""
from pysonos.exceptions import SoCoException
try:
# need catch exception if a coordinator is going to slave.
# this state will recover with group part.
self._soco_snapshot.restore(False)
except (TypeError, AttributeError, SoCoException):
_LOGGER.debug("Error on restore %s", self.entity_id)

# restore groups
if with_group and self._snapshot_group:
old = self._snapshot_group
actual = self.soco.group

##
# Master have not change, update group
if old.coordinator == actual.coordinator:
if self.soco is not old.coordinator:
# restore state of the groups
self._coordinator.restore(False)
remove = actual.members - old.members
add = old.members - actual.members

# remove new members
for soco_dev in list(remove):
soco_dev.unjoin()

# add old members
for soco_dev in list(add):
soco_dev.join(old.coordinator)
return

##
# old is already master, rejoin
if old.coordinator.group.coordinator == old.coordinator:
self.soco.join(old.coordinator)
return

##
# restore old master, update group
old.coordinator.unjoin()
coordinator = _get_entity_from_soco_uid(
self.hass, old.coordinator.uid)
coordinator.restore(False)

for s_dev in list(old.members):
if s_dev != old.coordinator:
s_dev.join(old.coordinator)

@soco_error()
@soco_coordinator
def set_sleep_timer(self, sleep_time):
Expand Down Expand Up @@ -1089,7 +1022,9 @@ def set_option(self, **data):
@property
def device_state_attributes(self):
"""Return entity specific state attributes."""
attributes = {ATTR_SONOS_GROUP: self._sonos_group}
attributes = {
ATTR_SONOS_GROUP: [e.entity_id for e in self._sonos_group],
}

if self._night_sound is not None:
attributes[ATTR_NIGHT_SOUND] = self._night_sound
Expand All @@ -1098,3 +1033,62 @@ def device_state_attributes(self):
attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance

return attributes


@soco_error()
def snapshot(entities, with_group):
"""Snapshot all the entities and optionally their groups."""
# pylint: disable=protected-access
from pysonos.snapshot import Snapshot

# Find all affected players
entities = set(entities)
if with_group:
for entity in list(entities):
entities.update(entity._sonos_group)

for entity in entities:
entity._soco_snapshot = Snapshot(entity.soco)
entity._soco_snapshot.snapshot()
if with_group:
entity._snapshot_group = entity._sonos_group.copy()
else:
entity._snapshot_group = None


@soco_error()
def restore(entities, with_group):
"""Restore snapshots for all the entities."""
# pylint: disable=protected-access
from pysonos.exceptions import SoCoException

# Find all affected players
entities = set(e for e in entities if e._soco_snapshot)
if with_group:
for entity in [e for e in entities if e._snapshot_group]:
entities.update(entity._snapshot_group)

# Pause all current coordinators
for entity in (e for e in entities if e.is_coordinator):
if entity.state == STATE_PLAYING:
entity.media_pause()

# Bring back the original group topology and clear pysonos cache
if with_group:
for entity in (e for e in entities if e._snapshot_group):
if entity._snapshot_group[0] == entity:
entity.join(entity._snapshot_group)
entity.soco._zgs_cache.clear()

# Restore slaves, then coordinators
slaves = [e for e in entities if not e.is_coordinator]
coordinators = [e for e in entities if e.is_coordinator]
for entity in slaves + coordinators:
try:
entity._soco_snapshot.restore()
except (TypeError, AttributeError, SoCoException) as ex:
# Can happen if restoring a coordinator onto a current slave
_LOGGER.warning("Error on restore %s: %s", entity.entity_id, ex)

entity._soco_snapshot = None
entity._snapshot_group = None
41 changes: 27 additions & 14 deletions tests/components/sonos/test_media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ def get_sonos_favorites(self):
return []


class CacheMock():
"""Mock class for the _zgs_cache property on pysonos.SoCo object."""

def clear(self):
"""Clear cache."""
pass


class SoCoMock():
"""Mock class for the pysonos.SoCo object."""

Expand All @@ -63,6 +71,7 @@ def __init__(self, ip):
self.dialog_mode = False
self.music_library = MusicLibraryMock()
self.avTransport = AvTransportMock()
self._zgs_cache = CacheMock()

def get_sonos_favorites(self):
"""Get favorites list from sonos."""
Expand Down Expand Up @@ -126,7 +135,7 @@ def add_entities_factory(hass):
"""Add entities factory."""
def add_entities(entities, update_befor_add=False):
"""Fake add entity."""
hass.data[sonos.DATA_SONOS].entities = entities
hass.data[sonos.DATA_SONOS].entities = list(entities)

return add_entities

Expand Down Expand Up @@ -162,7 +171,7 @@ def test_ensure_setup_discovery(self, *args):
'host': '192.0.2.1'
})

entities = list(self.hass.data[sonos.DATA_SONOS].entities)
entities = self.hass.data[sonos.DATA_SONOS].entities
assert len(entities) == 1
assert entities[0].name == 'Kitchen'

Expand Down Expand Up @@ -242,7 +251,7 @@ def test_ensure_setup_config_hosts_list(self, *args):
def test_ensure_setup_sonos_discovery(self, *args):
"""Test a single device using the autodiscovery provided by Sonos."""
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass))
entities = list(self.hass.data[sonos.DATA_SONOS].entities)
entities = self.hass.data[sonos.DATA_SONOS].entities
assert len(entities) == 1
assert entities[0].name == 'Kitchen'

Expand All @@ -254,7 +263,7 @@ def test_sonos_set_sleep_timer(self, set_sleep_timerMock, *args):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1'
})
entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
entity = self.hass.data[sonos.DATA_SONOS].entities[-1]
entity.hass = self.hass

entity.set_sleep_timer(30)
Expand All @@ -268,7 +277,7 @@ def test_sonos_clear_sleep_timer(self, set_sleep_timerMock, *args):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1'
})
entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
entity = self.hass.data[sonos.DATA_SONOS].entities[-1]
entity.hass = self.hass

entity.set_sleep_timer(None)
Expand All @@ -282,7 +291,7 @@ def test_set_alarm(self, pysonos_mock, alarm_mock, *args):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1'
})
entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
entity = self.hass.data[sonos.DATA_SONOS].entities[-1]
entity.hass = self.hass
alarm1 = alarms.Alarm(pysonos_mock)
alarm1.configure_mock(_alarm_id="1", start_time=None, enabled=False,
Expand Down Expand Up @@ -312,11 +321,14 @@ def test_sonos_snapshot(self, snapshotMock, *args):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1'
})
entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
entities = self.hass.data[sonos.DATA_SONOS].entities
entity = entities[-1]
entity.hass = self.hass

snapshotMock.return_value = True
entity.snapshot()
entity.soco.group = mock.MagicMock()
entity.soco.group.members = [e.soco for e in entities]
sonos.snapshot(entities, True)
assert snapshotMock.call_count == 1
assert snapshotMock.call_args == mock.call()

Expand All @@ -330,13 +342,14 @@ def test_sonos_restore(self, restoreMock, *args):
sonos.setup_platform(self.hass, {}, add_entities_factory(self.hass), {
'host': '192.0.2.1'
})
entity = list(self.hass.data[sonos.DATA_SONOS].entities)[-1]
entities = self.hass.data[sonos.DATA_SONOS].entities
entity = entities[-1]
entity.hass = self.hass

restoreMock.return_value = True
entity._snapshot_coordinator = mock.MagicMock()
entity._snapshot_coordinator.soco_entity = SoCoMock('192.0.2.17')
entity._soco_snapshot = Snapshot(entity._player)
entity.restore()
entity._snapshot_group = mock.MagicMock()
entity._snapshot_group.members = [e.soco for e in entities]
entity._soco_snapshot = Snapshot(entity.soco)
sonos.restore(entities, True)
assert restoreMock.call_count == 1
assert restoreMock.call_args == mock.call(False)
assert restoreMock.call_args == mock.call()

0 comments on commit 095a0d1

Please sign in to comment.