"""Provides handling for ZoneGroupState information. ZoneGroupState XML payloads are received from both: * zoneGroupTopology.GetZoneGroupState()['ZoneGroupState'] * zoneGroupTopology subscription event callbacks The ZoneGroupState payloads are identical between all speakers in a household, but may be generated with differing orders for contained ZoneGroup or ZoneGroupMember elements and children. To benefit from similar contents, payloads are passed through an XSL transformation to normalize the data, to allow simple equality comparisons, and to avoid unnecessary reprocessing of identical data. Since the payloads are identical between all speakers, we can use a common cache per household. As satellites can sometimes deliver outdated payloads when they are directly polled, these requests are instead forwarded to the parent device. Example payload contents: """ import asyncio import logging import time from weakref import WeakSet from lxml import etree as LXML from . import config from .events_base import SubscriptionBase from .exceptions import NotSupportedException, SoCoException, SoCoUPnPException from .groups import ZoneGroup POLLING_CACHE_TIMEOUT = 5 NEVER_TIME = -1200.0 ZGS_ATTRIB_MAPPING = { "BootSeq": "_boot_seqnum", "ChannelMapSet": "_channel_map", "HTSatChanMapSet": "_ht_sat_chan_map", "MicEnabled": "_mic_enabled", "UUID": "_uid", "VoiceConfigState": "_voice_config_state", "ZoneName": "_player_name", } ZGS_XSLT = """ """ ZGS_TRANSFORM = LXML.XSLT(LXML.fromstring(ZGS_XSLT)) # pylint:disable=I1101 _LOG = logging.getLogger(__name__) class ZoneGroupState: """Handles processing and caching of ZoneGroupState payloads. Only one ZoneGroupState instance is created per Sonos household. """ def __init__(self): """Initialize the ZoneGroupState instance.""" self.all_zones = set() self.groups = set() self.visible_zones = set() self._cache_until = NEVER_TIME self._last_zgs = None self._subscriptions = WeakSet() # Statistics self.total_requests = 0 self.processed_count = 0 def clear_cache(self): """Clear the cache timestamp.""" self._cache_until = NEVER_TIME def add_subscription(self, subscription: SubscriptionBase): """Start tracking a ZoneGroupTopology subscription.""" if ( subscription.service.service_type == "ZoneGroupTopology" and subscription not in self._subscriptions ): self._subscriptions.add(subscription) _LOG.debug( "Monitoring ZoneGroupTopology subscription %s on %s", subscription.sid, subscription.service.soco, ) def remove_subscription(self, subscription: SubscriptionBase): """Stop tracking a ZoneGroupTopology subscription.""" if subscription in self._subscriptions: self._subscriptions.remove(subscription) _LOG.debug( "Discarded unsubscribed subscription %s from %s, %d remaining", subscription.sid, subscription.service.soco, len(self._subscriptions), ) @property def has_subscriptions(self): """Return True if active subscriptions are updating this ZoneGroupState.""" stale_subscriptions = [sub for sub in self._subscriptions if not sub.time_left] for sub in stale_subscriptions: _LOG.debug("Discarding stale subscription: %s", sub.sid) self.remove_subscription(sub) return bool(self._subscriptions) def clear_zone_groups(self): """Clear all known group sets.""" self.groups.clear() self.all_zones.clear() self.visible_zones.clear() def poll(self, soco): """Poll using the provided SoCo instance and process the payload.""" # pylint: disable=protected-access if self.has_subscriptions: self.total_requests += 1 _LOG.debug( "Subscriptions (%s) still active during poll for %s, using cache", len(self._subscriptions), soco.ip_address, ) return if time.monotonic() < self._cache_until: self.total_requests += 1 _LOG.debug( "Cache still active (GetZoneGroupState) during poll for %s", soco.ip_address, ) return if soco._is_satellite: # Satellites can return outdated information, use the parent _LOG.debug( "Poll request on satellite (%s), using parent (%s)", soco.ip_address, soco._satellite_parent.ip_address, ) soco = soco._satellite_parent # On large (about 20+ players) systems, GetZoneGroupState() can cause # the target Sonos player to return an HTTP 501 error, raising a # SoCoUPnPException. try: zgs = soco.zoneGroupTopology.GetZoneGroupState()["ZoneGroupState"] self.process_payload(payload=zgs, source="poll", source_ip=soco.ip_address) self._cache_until = time.monotonic() + POLLING_CACHE_TIMEOUT _LOG.debug("Extending ZGS cache by %ss", POLLING_CACHE_TIMEOUT) # In the event of failure, we fall back to using a ZGT event to # determine the ZGS. Fallback behaviour can be disabled by setting the # config.ZGT_EVENT_FALLBACK flag to False. except SoCoUPnPException as soco_upnp_exception: _LOG.debug( "Exception (%s) raised on 'GetZoneGroupState()'", soco_upnp_exception, ) if config.ZGT_EVENT_FALLBACK is False: _LOG.debug("ZGT event fallback disabled (config.ZGT_EVENT_FALLBACK)") raise NotSupportedException( "'GetZoneGroupState()' call fails on large Sonos systems " "and event fallback is disabled" ) from soco_upnp_exception _LOG.debug("Falling back to using a ZGT event") try: self.update_zgs_by_event(soco) except Exception as soco_exception: raise soco_exception from soco_upnp_exception def update_zgs_by_event(self, speaker): """ Fall back to updating the ZGS using a ZGT event. Use of the 'events_twisted' module is not currently supported. """ if config.EVENTS_MODULE.__name__ == "soco.events": _LOG.debug("Updating ZGS using standard 'events' module") self.update_zgs_by_event_default(speaker) elif config.EVENTS_MODULE.__name__ == "soco.events_asyncio": _LOG.debug("Updating ZGS using 'events_asyncio' module") # Explicit asyncio event loop control required for Python 3.6 loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) loop.run_until_complete(ZoneGroupState.update_zgs_by_event_asyncio(speaker)) asyncio.set_event_loop(None) loop.close() # From Python 3.7, we can just use the single statement: # asyncio.run(ZoneGroupState.update_zgs_events_asyncio(speaker)) elif config.EVENTS_MODULE.__name__ == "soco.events_twisted": # Future: Insert code here to handle the 'events_twisted' case raise SoCoException( "ZGT event fallback not yet implemented when using the " "'events_twisted' module" ) else: # In case any additional events frameworks come along ... raise SoCoException( "ZGT event fallback not implemented for " f"'{config.EVENTS_MODULE.__name__}' module" ) def update_zgs_by_event_default(self, speaker): """ Update the ZGS using the default events module. """ sub = speaker.zoneGroupTopology.subscribe() event = sub.events.get(timeout=1.0) sub.unsubscribe() zgs = event.variables.get("zone_group_state") self.process_payload(payload=zgs, source="event", source_ip=speaker.ip_address) @staticmethod async def update_zgs_by_event_asyncio(speaker): """ Update ZGS using events_asyncio. When the event is received, the events_asyncio notify handler will call 'process_payload' with the updated ZGS. """ from . import events_asyncio # pylint: disable=C0415 event_listener_is_running = events_asyncio.event_listener.is_running sub = await speaker.zoneGroupTopology.subscribe() await asyncio.sleep(0.25) await sub.unsubscribe() if not event_listener_is_running: # The event listener was started as a result of our # subscribe() call, so stop it await events_asyncio.event_listener.async_stop() def process_payload(self, payload, source, source_ip): """Update using the provided XML payload.""" self.total_requests += 1 tree = normalize_zgs_xml(payload) normalized_zgs = str(tree) if normalized_zgs == self._last_zgs: _LOG.debug( "Duplicate ZGS received from %s (%s), ignoring", source_ip, source ) return self.processed_count += 1 _LOG.debug( "Updating ZGS with %s payload from %s (%s/%s processed)", source, source_ip, self.processed_count, self.total_requests, ) self.update_soco_instances(tree) self._last_zgs = normalized_zgs def parse_zone_group_member(self, member_element): """Parse a ZoneGroupMember or Satellite element from Zone Group State, create a SoCo instance for the member, set basic attributes and return it.""" # pylint: disable=protected-access # Create a SoCo instance for each member. Because SoCo # instances are singletons, this is cheap if they have already # been created, and useful if they haven't. We can then # update various properties for that instance. member_attribs = member_element.attrib # Example Location contents: # http://192.168.1.100:1400/xml/device_description.xml ip_addr = member_attribs["Location"].split("//")[1].split(":")[0] zone = config.SOCO_CLASS(ip_addr) for key, attrib in ZGS_ATTRIB_MAPPING.items(): setattr(zone, attrib, member_attribs.get(key)) # Example ChannelMapSet (stereo pair) contents: # RINCON_001XXX1400:LF,LF;RINCON_002XXX1400:RF,RF # Example HTSatChanMapSet (home theater) contents: # RINCON_001XXX1400:LF,RF;RINCON_002XXX1400:LR;RINCON_003XXX1400:RR for channel_map in list( filter(None, [zone._channel_map, zone._ht_sat_chan_map]) ): for channel in channel_map.split(";"): if channel.startswith(zone._uid): zone._channel = channel.split(":")[-1] # Add the zone to the set of all members, and to the set # of visible members if appropriate if member_attribs.get("Invisible") != "1": self.visible_zones.add(zone) self.all_zones.add(zone) return zone def update_soco_instances(self, tree): """Update all SoCo instances with the provided payload.""" # pylint: disable=protected-access self.clear_zone_groups() # Compatibility fallback for pre-10.1 firmwares # where a "ZoneGroups" element is not used zone_groups = tree.find("ZoneGroups") if zone_groups is None: zone_groups = tree for group_element in zone_groups.findall("ZoneGroup"): coordinator_uid = group_element.attrib["Coordinator"] group_uid = group_element.attrib["ID"] group_coordinator = None members = set() for member_element in group_element.findall("ZoneGroupMember"): zone = self.parse_zone_group_member(member_element) zone._is_satellite = False zone._satellite_parent = None if zone._uid == coordinator_uid: group_coordinator = zone zone._is_coordinator = True else: zone._is_coordinator = False # is_bridge doesn't change, but it does no real harm to # set/reset it here, just in case the zone has not been seen # before zone._is_bridge = member_element.attrib.get("IsZoneBridge") == "1" # add the zone to the members for this group members.add(zone) # Loop over Satellite elements if present, and process as for # ZoneGroup elements satellite_elements = member_element.findall("Satellite") zone._has_satellites = bool(satellite_elements) for satellite_element in satellite_elements: satellite = self.parse_zone_group_member(satellite_element) satellite._is_satellite = True satellite._satellite_parent = zone # Assume a satellite can't be a bridge or coordinator, so # no need to check. members.add(satellite) self.groups.add(ZoneGroup(group_uid, group_coordinator, members)) def normalize_zgs_xml(xml): """Normalize the ZoneGroupState payload and return an lxml ElementTree instance.""" parser = LXML.XMLParser(remove_blank_text=True) # pylint:disable=I1101 tree = LXML.fromstring(xml, parser) # pylint:disable=I1101 return ZGS_TRANSFORM(tree)