Skip to content

Commit

Permalink
new SoCo version
Browse files Browse the repository at this point in the history
  • Loading branch information
spfuu committed Jan 23, 2015
1 parent b406499 commit de1000a
Show file tree
Hide file tree
Showing 7 changed files with 145 additions and 189 deletions.
4 changes: 2 additions & 2 deletions server.sonos/soco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@
__license__ = 'MIT License'


from .core import discover, SoCo, SonosDiscovery
from .core import SoCo
from .discovery import discover
from .exceptions import SoCoException, UnknownSoCoException

# You really should not `import *` - it is poor practice
# but if you do, here is what you get:
__all__ = [
'discover',
'SonosDiscovery',
'SoCo',
'SoCoException',
'UnknownSoCoException',
Expand Down
3 changes: 2 additions & 1 deletion server.sonos/soco/alarms.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from datetime import datetime
import re
import weakref
from .core import discover, PLAY_MODES
from .core import PLAY_MODES
from .discovery import discover
from .xml import XML

log = logging.getLogger(__name__) # pylint: disable=C0103
Expand Down
163 changes: 4 additions & 159 deletions server.sonos/soco/core.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
# -*- coding: utf-8 -*-
# pylint: disable=C0302,fixme, protected-access
""" The core module contains SonosDiscovery and SoCo classes that implement
""" The core module contains the SoCo class that implements
the main entry to the SoCo functionality
"""

from __future__ import unicode_literals

import select
import socket
import logging
from textwrap import dedent
import re
import itertools
import requests
import time
import struct

from .services import DeviceProperties, ContentDirectory
from .services import RenderingControl, AVTransport, ZoneGroupTopology
Expand All @@ -32,111 +27,6 @@
_LOG = logging.getLogger(__name__)


def discover(timeout=1, include_invisible=False):
""" Discover Sonos zones on the local network.
Return an set of visible SoCo instances for each zone found.
Include invisible zones (bridges and slave zones in stereo pairs if
`include_invisible` is True. Will block for up to `timeout` seconds, after
which return `None` if no zones found.
"""

# pylint: disable=invalid-name
PLAYER_SEARCH = dedent("""\
M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 1
ST: urn:schemas-upnp-org:device:ZonePlayer:1
""").encode('utf-8')
MCAST_GRP = "239.255.255.250"
MCAST_PORT = 1900

_sock = socket.socket(
socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
# UPnP v1.0 requires a TTL of 4
_sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL,
struct.pack("B", 4))
# Send a few times. UDP is unreliable
_sock.sendto(really_utf8(PLAYER_SEARCH), (MCAST_GRP, MCAST_PORT))
_sock.sendto(really_utf8(PLAYER_SEARCH), (MCAST_GRP, MCAST_PORT))
_sock.sendto(really_utf8(PLAYER_SEARCH), (MCAST_GRP, MCAST_PORT))

t0 = time.time()
while True:
# Check if the timeout is exceeded. We could do this check just
# before the currently only continue statement of this loop,
# but I feel it is safer to do it here, so that we do not forget
# to do it if/when another continue statement is added later.
# Note: this is sensitive to clock adjustments. AFAIK there
# is no monotonic timer available before Python 3.3.
t1 = time.time()
if t1-t0 > timeout:
return None

# The timeout of the select call is set to be no greater than
# 100ms, so as not to exceed (too much) the required timeout
# in case the loop is executed more than once.
response, _, _ = select.select([_sock], [], [], min(timeout, 0.1))

# Only Zone Players should respond, given the value of ST in the
# PLAYER_SEARCH message. However, to prevent misbehaved devices
# on the network to disrupt the discovery process, we check that
# the response contains the "Sonos" string; otherwise we keep
# waiting for a correct response.
#
# Here is a sample response from a real Sonos device (actual numbers
# have been redacted):
# HTTP/1.1 200 OK
# CACHE-CONTROL: max-age = 1800
# EXT:
# LOCATION: http://***.***.***.***:1400/xml/device_description.xml
# SERVER: Linux UPnP/1.0 Sonos/26.1-76230 (ZPS3)
# ST: urn:schemas-upnp-org:device:ZonePlayer:1
# USN: uuid:RINCON_B8*************00::urn:schemas-upnp-org:device:
# ZonePlayer:1
# X-RINCON-BOOTSEQ: 3
# X-RINCON-HOUSEHOLD: Sonos_7O********************R7eU

if response:
data, addr = _sock.recvfrom(1024)
if b"Sonos" not in data:
continue

# Now we have an IP, we can build a SoCo instance and query that
# player for the topology to find the other players. It is much
# more efficient to rely upon the Zone Player's ability to find
# the others, than to wait for query responses from them
# ourselves.
zone = config.SOCO_CLASS(addr[0])
if include_invisible:
return zone.all_zones
else:
return zone.visible_zones


class SonosDiscovery(object): # pylint: disable=R0903
"""Retained for backward compatibility only. Will be removed in future
releases
.. deprecated:: 0.7
Use :func:`discover` instead.
"""

def __init__(self):
import warnings
warnings.warn("SonosDiscovery is deprecated. Use discover instead.")

@staticmethod
def get_speaker_ips():
""" Deprecated in favour of discover() """
import warnings
warnings.warn("get_speaker_ips is deprecated. Use discover instead.")
return [i.ip_address for i in discover()]


class _ArgsSingleton(type):
""" A metaclass which permits only a single instance of each derived class
sharing the same `_class_group` class attribute to exist for any given set
Expand Down Expand Up @@ -316,6 +206,8 @@ def __init__(self, ip_address):
self._visible_zones = set()
self._zgs_cache = None

_LOG.debug("Created SoCo instance for ip: %s", ip_address)

def __str__(self):
return "<{0} object at ip {1}>".format(
self.__class__.__name__, self.ip_address)
Expand Down Expand Up @@ -455,19 +347,6 @@ def cross_fade(self, crossfade):
('CrossfadeMode', crossfade_value)
])

@property
def speaker_ip(self):
"""Retained for backward compatibility only. Will be removed in future
releases
.. deprecated:: 0.7
Use :attr:`ip_address` instead.
"""
import warnings
warnings.warn("speaker_ip is deprecated. Use ip_address instead.")
return self.ip_address

def play_from_queue(self, index, start=True):
""" Play a track from the queue by index. The index number is
required as an argument, where the first index is 0.
Expand Down Expand Up @@ -1153,39 +1032,6 @@ def get_speaker_info(self, refresh=False):

return self.speaker_info

def get_group_coordinator(self, zone_name):
"""
.. deprecated:: 0.8
Use :meth:`group` or :meth:`all_groups` instead.
"""
import warnings
warnings.warn(
"get_group_coordinator is deprecated. "
"Use the group or all_groups methods instead")
for group in self.all_groups:
for member in group:
if member.player_name == zone_name:
return group.coordinator.ip_address
return None

def get_speakers_ip(self, refresh=False):
""" Get the IP addresses of all the Sonos speakers in the network.
Arguments:
refresh -- Refresh the speakers IP cache. Ignored. For backward
compatibility only
Returns:
a set of IP addresses of the Sonos speakers.
.. deprecated:: 0.8
"""
# pylint: disable=star-args, unused-argument
return set(z.ip_address for z in itertools.chain(*self.all_groups))

def get_current_transport_info(self):
""" Get the current playback state
Expand Down Expand Up @@ -1659,8 +1505,7 @@ def add_uri_to_queue(self, uri):
def add_to_queue(self, queueable_item):
""" Adds a queueable item to the queue """
metadata = to_didl_string(queueable_item)
if isinstance(metadata, str):
metadata.encode('utf-8')
metadata.encode('utf-8')
response = self.avTransport.AddURIToQueue([
('InstanceID', 0),
('EnqueuedURI', queueable_item.resources[0].uri),
Expand Down
33 changes: 22 additions & 11 deletions server.sonos/soco/data_structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
# Although Sonos uses ContentDirectory v1, the document for v2 is more helpful:
# http://upnp.org/specs/av/UPnP-av-ContentDirectory-v2-Service.pdf

# TODO: Add Desc element

from __future__ import unicode_literals

Expand Down Expand Up @@ -79,7 +78,7 @@ def from_didl_string(string):
items.append(cls.from_element(elt))
else:
# <desc> elements are allowed as an immediate child of <DIDL-Lite>
# according to the spec, but I have not seen one in Sonos, so
# according to the spec, but I have not seen one there in Sonos, so
# we treat them as illegal. May need to fix this if this
# causes problems.
raise DIDLMetadataError("Illegal child of DIDL element: <%s>"
Expand Down Expand Up @@ -284,7 +283,7 @@ class DidlObject(DidlMetaClass(str('DidlMetaClass'), (object,), {})):
}

def __init__(self, title, parent_id, item_id, restricted=True,
resources=None, **kwargs):
resources=None, desc='RINCON_AssociatedZPUDN', **kwargs):
r"""Construct and initialize a DidlObject.
Args:
Expand All @@ -293,6 +292,9 @@ def __init__(self, title, parent_id, item_id, restricted=True,
item_id (str): The ID for the item
restricted (bool): Whether the item can be modified
resources (list): A list of resources for this object
desc (str): A didl descriptor, default RINCON_AssociatedZPUDN. This
is not the same as "description"! It is used for identifying
the relevant music service
**kwargs: Extra metadata. What is allowed depends on the
_translation class attribute, which in turn depends on the DIDL
class
Expand All @@ -319,13 +321,17 @@ def __init__(self, title, parent_id, item_id, restricted=True,
# Resources is multi-valued, and dealt with separately
self.resources = [] if resources is None else resources

# According to the spec, there may be one or more desc values. Sonos
# only seems to use one, so we won't bother with a list
self.desc = desc

for key, value in kwargs.items():
# For each attribute, check to see if this class allows it
if key not in self._translation:
raise ValueError(
'The key \'{0}\' is not allowed as an argument. Only '
'these keys are allowed: parent_id, item_id, title, '
'restricted, resources, '
'restricted, resources, desc'
' {1}'.format(key, ', '.join(self._translation.keys())))
# It is an allowed attribute. Set it as an attribute on self, so
# that it can be accessed as Classname.attribute in the normal
Expand All @@ -346,10 +352,10 @@ def from_element(cls, element):
# Check we have the right sort of element. tag can be an empty string
# which indicates that any tag is allowed (see eg the musicAlbum DIDL
# class)
if not element.tag.endswith(cls.tag):
raise DIDLMetadataError(
"Wrong element. Expected '<{0}>',"
" got '<{1}>'".format(cls.tag, element.tag))
# if not element.tag.endswith(cls.tag):
# raise DIDLMetadataError(
# "Wrong element. Expected '<{0}>',"
# " got '<{1}>'".format(cls.tag, element.tag))
# and that the upnp matches what we are expecting
item_class = element.find(ns_tag('upnp', 'class')).text
if item_class != cls.item_class:
Expand Down Expand Up @@ -384,6 +390,9 @@ def from_element(cls, element):
resources.append(
DidlResource.from_element(res_elt))

# and the desc element (There is only one in Sonos)
desc = element.findtext(ns_tag('', 'desc'))

# Get values of the elements listed in _translation and add them to
# the content dict
content = {}
Expand All @@ -401,7 +410,8 @@ def from_element(cls, element):
# Now pass the content dict we have just built to the main
# constructor, as kwargs, to create the object
return cls(title=title, parent_id=parent_id, item_id=item_id,
restricted=restricted, resources=resources, **content)
restricted=restricted, resources=resources, desc=desc,
**content)

@classmethod
def from_dict(cls, content):
Expand Down Expand Up @@ -487,6 +497,7 @@ def to_dict(self):
content['title'] = self.title
if self.resources != []:
content['resources'] = self.resources
content['desc'] = self.desc
return content

def to_element(self, include_namespaces=False):
Expand Down Expand Up @@ -550,8 +561,8 @@ def to_element(self, include_namespaces=False):
# And the desc element
desc_attrib = {'id': 'cdudn', 'nameSpace':
'urn:schemas-rinconnetworks-com:metadata-1-0/'}
desc = XML.SubElement(elt, 'desc', desc_attrib)
desc.text = 'RINCON_AssociatedZPUDN'
desc_elt = XML.SubElement(elt, 'desc', desc_attrib)
desc_elt.text = self.desc

return elt

Expand Down
Loading

0 comments on commit de1000a

Please sign in to comment.