Example #1
0
    def test_add_item_to_sonos_playlist(self, moco):
        moco.contentDirectory.reset_mock()

        playlist = mock.Mock()
        playlist.item_id = 7

        track = mock.Mock()
        track.uri = 'fake_uri'
        track.didl_metadata = XML.Element('a')

        update_id = 100

        moco.contentDirectory.Browse.return_value = {
            'NumberReturned': '0',
            'Result':
            '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"></DIDL-Lite>',
            'TotalMatches': '0',
            'UpdateID': update_id
        }

        moco.add_item_to_sonos_playlist(track, playlist)
        moco.contentDirectory.Browse.assert_called_once_with([
            ('ObjectID', playlist.item_id),
            ('BrowseFlag', 'BrowseDirectChildren'), ('Filter', '*'),
            ('StartingIndex', 0), ('RequestedCount', 1), ('SortCriteria', '')
        ])
        moco.avTransport.AddURIToSavedQueue.assert_called_once_with([
            ('InstanceID', 0), ('UpdateID', update_id),
            ('ObjectID', playlist.item_id), ('EnqueuedURI', track.uri),
            ('EnqueuedURIMetaData', XML.tostring(track.didl_metadata)),
            ('AddAtIndex', 4294967295)
        ])
Example #2
0
def common_tests(class_, xml_, dict_, parent_id, helpers):
    """Common tests for the MS classes."""
    xml_content = XML.fromstring(xml_.encode("utf8"))

    # MusicServiceItem.from_xml and MusicServiceItem.to_dict
    item_from_xml = class_.from_xml(xml_content, FAKE_MUSIC_SERVICE, parent_id)
    assert item_from_xml.to_dict == dict_

    # MusicServiceItem.from_dict and MusicServiceItem.to_dict
    item_from_dict = class_.from_dict(dict_)
    assert item_from_dict.to_dict == dict_

    # MusicServiceItem.didl_metadata
    # NOTE! These tests is reliant on the attributes being put in the same
    # order by ElementTree and so it might fail if that changes
    if item_from_xml.can_play:
        dict_encoded = {}
        for key, value in dict_.items():
            try:
                is_str = isinstance(value, unicode)
            except NameError:
                is_str = isinstance(value, str)
            if is_str:
                dict_encoded[key] = (escape(value).encode(
                    "ascii", "xmlcharrefreplace").decode("ascii"))

            else:
                dict_encoded[key] = value
        didl = DIDL_TEMPLATE.format(item_class=class_.item_class,
                                    **dict_encoded)

        assert helpers.compare_xml(item_from_xml.didl_metadata,
                                   XML.fromstring(didl))
        assert helpers.compare_xml(item_from_dict.didl_metadata,
                                   XML.fromstring(didl))
    else:
        with pytest.raises(DIDLMetadataError):
            # pylint: disable=pointless-statement
            item_from_xml.didl_metadata

    # Text attributes with mandatory content
    for name in ["item_id", "extended_id", "title", "service_id"]:
        getter_attributes_test(name, item_from_xml, item_from_dict,
                               dict_[name])
    # Text attributes with voluntary content
    for name in ["parent_id", "album_art_uri"]:
        getter_attributes_test(name, item_from_xml, item_from_dict,
                               dict_.get(name))
    # Boolean attribute
    getter_attributes_test("can_play", item_from_xml, item_from_dict,
                           bool(dict_.get("can_play")))
    return item_from_xml, item_from_dict
Example #3
0
def common_tests(class_, xml_, dict_, parent_id):
    """Common tests for the MS classes."""
    xml_content = XML.fromstring(xml_.encode('utf8'))

    # MusicServiceItem.from_xml and MusicServiceItem.to_dict
    item_from_xml = class_.from_xml(xml_content, FAKE_MUSIC_SERVICE, parent_id)
    assert item_from_xml.to_dict == dict_

    # MusicServiceItem.from_dict and MusicServiceItem.to_dict
    item_from_dict = class_.from_dict(dict_)
    assert item_from_dict.to_dict == dict_

    # MusicServiceItem.didl_metadata
    # NOTE! These tests is reliant on the attributes being put in the same
    # order by ElementTree and so it might fail if that changes
    if item_from_xml.can_play:
        dict_encoded = {}
        for key, value in dict_.items():
            try:
                is_str = isinstance(value, unicode)
            except NameError:
                is_str = isinstance(value, str)
            if is_str:
                dict_encoded[key] = escape(value).\
                    encode('ascii', 'xmlcharrefreplace').decode('ascii')

            else:
                dict_encoded[key] = value
        didl = DIDL_TEMPLATE.format(item_class=class_.item_class,
                                    **dict_encoded)
        assert XML.tostring(item_from_xml.didl_metadata).decode('ascii') == \
            didl
        assert XML.tostring(item_from_dict.didl_metadata).decode('ascii') == \
            didl
    else:
        with pytest.raises(DIDLMetadataError):
            # pylint: disable=pointless-statement
            item_from_xml.didl_metadata

    # Text attributes with mandatory content
    for name in ['item_id', 'extended_id', 'title', 'service_id']:
        getter_attributes_test(name, item_from_xml, item_from_dict,
                               dict_[name])
    # Text attributes with voluntary content
    for name in ['parent_id', 'album_art_uri']:
        getter_attributes_test(name, item_from_xml, item_from_dict,
                               dict_.get(name))
    # Boolean attribute
    getter_attributes_test('can_play', item_from_xml, item_from_dict,
                           bool(dict_.get('can_play')))
    return item_from_xml, item_from_dict
Example #4
0
def common_tests(class_, xml_, dict_, parent_id):
    """Common tests for the MS classes"""
    xml_content = XML.fromstring(xml_.encode('utf8'))

    # MusicServiceItem.from_xml and MusicServiceItem.to_dict
    item_from_xml = class_.from_xml(xml_content, FAKE_MUSIC_SERVICE, parent_id)
    assert item_from_xml.to_dict == dict_

    # MusicServiceItem.from_dict and MusicServiceItem.to_dict
    item_from_dict = class_.from_dict(dict_)
    assert item_from_dict.to_dict == dict_

    # MusicServiceItem.didl_metadata
    # NOTE! These tests is reliant on the attributes being put in the same
    # order by ElementTree and so it might fail if that changes
    if item_from_xml.can_play:
        dict_encoded = {}
        for key, value in dict_.items():
            try:
                is_str = isinstance(value, unicode)
            except NameError:
                is_str = isinstance(value, str)
            if is_str:
                dict_encoded[key] = escape(value).\
                    encode('ascii', 'xmlcharrefreplace').decode('ascii')

            else:
                dict_encoded[key] = value
        didl = DIDL_TEMPLATE.format(item_class=class_.item_class,
                                    **dict_encoded)
        assert XML.tostring(item_from_xml.didl_metadata).decode('ascii') == \
            didl
        assert XML.tostring(item_from_dict.didl_metadata).decode('ascii') == \
            didl
    else:
        with pytest.raises(CannotCreateDIDLMetadata):
            # pylint: disable=pointless-statement
            item_from_xml.didl_metadata

    # Text attributes with mandatory content
    for name in ['item_id', 'extended_id', 'title', 'service_id']:
        getter_attributes_test(name, item_from_xml, item_from_dict,
                               dict_[name])
    # Text attributes with voluntary content
    for name in ['parent_id', 'album_art_uri']:
        getter_attributes_test(name, item_from_xml, item_from_dict,
                               dict_.get(name))
    # Boolean attribute
    getter_attributes_test('can_play', item_from_xml, item_from_dict,
                           bool(dict_.get('can_play')))
    return item_from_xml, item_from_dict
Example #5
0
def test_call():
    """Calling a command should result in an http request."""

    s = SoapMessage(
        endpoint='http://endpoint.example.com',
        method='getData',
        parameters=[('one', '1')],
        http_headers={'user-agent': 'sonos'},
        soap_action='ACTION',
        soap_header="<a_header>data</a_header>",
        namespace="http://namespace.com",
        other_arg=4)

    response = mock.MagicMock()
    response.headers = {}
    response.status_code = 200
    response.content = DUMMY_VALID_RESPONSE
    with mock.patch('requests.post', return_value=response) as fake_post:
        result = s.call()
        assert XML.tostring(result)
        fake_post.assert_called_once_with(
            'http://endpoint.example.com',
            headers={'SOAPACTION': '"ACTION"',
                     'Content-Type': 'text/xml; charset="utf-8"', 'user-agent':
                         'sonos'},
            data=mock.ANY, other_arg=4)
Example #6
0
 def test_didl_object_from_wrong_element(self):
     # Using the wrong element
     elt = XML.fromstring("""<res>URI</res>""")
     with pytest.raises(DIDLMetadataError) as excinfo:
         didl_object = data_structures.DidlObject.from_element(elt)
     assert "Wrong element. Expected <item> or <container>, "
     "got <res> for class object" in str(excinfo.value)
Example #7
0
def test_call():
    """Calling a command should result in an http request."""

    s = SoapMessage(
        endpoint="http://endpoint.example.com",
        method="getData",
        parameters=[("one", "1")],
        http_headers={"user-agent": "sonos"},
        soap_action="ACTION",
        soap_header="<a_header>data</a_header>",
        namespace="http://namespace.com",
        other_arg=4,
    )

    response = mock.MagicMock()
    response.headers = {}
    response.status_code = 200
    response.content = DUMMY_VALID_RESPONSE
    with mock.patch("requests.post", return_value=response) as fake_post:
        result = s.call()
        assert XML.tostring(result)
        fake_post.assert_called_once_with(
            "http://endpoint.example.com",
            headers={"SOAPACTION": '"ACTION"', "Content-Type": 'text/xml; charset="utf-8"', "user-agent": "sonos"},
            data=mock.ANY,
            other_arg=4,
        )
Example #8
0
def test_call():
    """Calling a command should result in an http request."""

    s = SoapMessage(
        endpoint="http://endpoint.example.com",
        method="getData",
        parameters=[("one", "1")],
        http_headers={"user-agent": "sonos"},
        soap_action="ACTION",
        soap_header="<a_header>data</a_header>",
        namespace="http://namespace.com",
        other_arg=4,
    )

    response = mock.MagicMock()
    response.headers = {}
    response.status_code = 200
    response.content = DUMMY_VALID_RESPONSE
    with mock.patch("requests.post", return_value=response) as fake_post:
        result = s.call()
        assert XML.tostring(result)
        fake_post.assert_called_once_with(
            "http://endpoint.example.com",
            headers={
                "SOAPACTION": '"ACTION"',
                "Content-Type": 'text/xml; charset="utf-8"',
                "user-agent": "sonos",
            },
            data=mock.ANY,
            other_arg=4,
        )
Example #9
0
    def call(self, method, args=None):
        http_headers = {
            'Accept-Encoding': 'gzip, deflate',
            'User-Agent': 'Linux UPnP/1.0 Sonos/26.99-12345'
        }

        message = SoapMessage(endpoint=self.endpoint,
                              method=method,
                              parameters=[] if args is None else args,
                              http_headers=http_headers,
                              soap_action="http://www.sonos.com/Services/1"
                              ".1#{0}".format(method),
                              soap_header=self.get_soap_header(),
                              namespace='http://www.sonos.com/Services/1.1',
                              timeout=self.timeout)

        try:
            result_elt = message.call()
        except SoapFault as exc:
            raise MusicServiceException(exc.faultstring, exc.faultcode)

        result = list(
            parse(XML.tostring(result_elt),
                  process_namespaces=True,
                  namespaces={
                      'http://www.sonos.com/Services/1.1': None
                  }).values())[0]

        return result if result is not None else {}
Example #10
0
def test_call():
    """ Calling a command should result in an http request"""

    s = SoapMessage(
        endpoint='http://endpoint.example.com',
        method='getData',
        parameters=[('one', '1')],
        http_headers={'user-agent': 'sonos'},
        soap_action='ACTION',
        soap_header="<a_header>data</a_header>",
        namespace="http://namespace.com",
        other_arg=4)

    response = mock.MagicMock()
    response.headers = {}
    response.status_code = 200
    response.content = DUMMY_VALID_RESPONSE
    with mock.patch('requests.post', return_value=response) as fake_post:
        result = s.call()
        assert XML.tostring(result)
        fake_post.assert_called_once_with(
            'http://endpoint.example.com',
            headers={'SOAPACTION': '"ACTION"',
                     'Content-Type': 'text/xml; charset="utf-8"', 'user-agent':
                         'sonos'},
            data=mock.ANY, other_arg=4)
Example #11
0
 def test_didl_object_from_wrong_element(self):
     # Using the wrong element
     elt = XML.fromstring("""<res>URI</res>""")
     with pytest.raises(DIDLMetadataError) as excinfo:
         didl_object = data_structures.DidlObject.from_element(elt)
     assert "Wrong element. Expected '<item>', got '<res>'" in str(
         excinfo.value)
Example #12
0
 def test_didl_object_to_element(self):
     didl_object = data_structures.DidlObject(title='a_title',
         parent_id='pid', item_id='iid', creator='a_creator')
     # we seem to have to go through this to get ElementTree to deal
     # with namespaces properly!
     elt = XML.fromstring(XML.tostring(didl_object.to_element(True)))
     elt2 = XML.fromstring('<dummy xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ' +
                 'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
                 'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">' +
                 '<item id="iid" parentID="pid" restricted="true">' +
                 '<dc:title>a_title</dc:title>' +
                 '<dc:creator>a_creator</dc:creator>' +
                 '<upnp:class>object</upnp:class><desc id="cdudn" '+
                 'nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">' +
                 'RINCON_AssociatedZPUDN</desc></item></dummy>')[0]
     assert_xml_equal(elt2, elt)
Example #13
0
 def test_create_didl_resource_to_from_element(self):
     res = data_structures.DidlResource('a%20uri', 'a:protocol:info:xx',
                                        bitrate=3)
     elt = res.to_element()
     assert XML.tostring(elt) == (
         b'<res bitrate="3" protocolInfo="a:protocol:info:xx">a%20uri</res>')
     assert data_structures.DidlResource.from_element(elt) == res
Example #14
0
 def test_create_didl_resource_to_from_element(self):
     res = data_structures.DidlResource('a%20uri', 'a:protocol:info:xx',
         bitrate=3)
     elt = res.to_element()
     assert XML.tostring(elt) == (
         b'<res bitrate="3" protocolInfo="a:protocol:info:xx">a%20uri</res>')
     assert data_structures.DidlResource.from_element(elt) == res
Example #15
0
 def test_didl_object_to_element(self):
     didl_object = data_structures.DidlObject(
         title='a_title', parent_id='pid', item_id='iid', creator='a_creator')
     # we seem to have to go through this to get ElementTree to deal
     # with namespaces properly!
     elt = XML.fromstring(XML.tostring(didl_object.to_element(True)))
     elt2 = XML.fromstring(
         '<dummy xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" ' +
         'xmlns:dc="http://purl.org/dc/elements/1.1/" ' +
         'xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">' +
         '<item id="iid" parentID="pid" restricted="true">' +
         '<dc:title>a_title</dc:title>' +
         '<dc:creator>a_creator</dc:creator>' +
         '<upnp:class>object</upnp:class><desc id="cdudn" ' +
         'nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">' +
         'RINCON_AssociatedZPUDN</desc></item></dummy>')[0]
     assert_xml_equal(elt2, elt)
Example #16
0
 def test_didl_object_from_element(self):
     elt = XML.fromstring(self.didl_xml)
     didl_object = data_structures.DidlObject.from_element(elt)
     assert didl_object.title == 'the_title'
     assert didl_object.parent_id == 'pid'
     assert didl_object.item_id == 'iid'
     assert didl_object.creator == 'a_creator'
     assert didl_object.desc == 'DUMMY'
     assert didl_object.item_class == 'object'
Example #17
0
    def test_didl_object_from_element_unoff_subelement(self):
        """Test that for a DidlObject created from an element with an
        unofficial .# specified sub class, that the sub class is
        completely ignored

        """
        elt = XML.fromstring(self.didl_xml.replace("object", "object.#SubClass"))
        didl_object = data_structures.DidlObject.from_element(elt)
        assert didl_object.item_class == "object"
Example #18
0
 def test_didl_object_from_element(self):
     elt = XML.fromstring(self.didl_xml)
     didl_object = data_structures.DidlObject.from_element(elt)
     assert didl_object.title == "the_title"
     assert didl_object.parent_id == "pid"
     assert didl_object.item_id == "iid"
     assert didl_object.creator == "a_creator"
     assert didl_object.desc == "DUMMY"
     assert didl_object.item_class == "object"
Example #19
0
    def test_didl_object_from_element_unoff_subelement(self):
        """Test that for a DidlObject created from an element with an
        unofficial .# specified sub class, that the sub class is
        completely ignored

        """
        elt = XML.fromstring(self.didl_xml.replace("object", "object.#SubClass"))
        didl_object = data_structures.DidlObject.from_element(elt)
        assert didl_object.item_class == "object"
Example #20
0
 def test_didl_object_from_element(self):
     elt = XML.fromstring(self.didl_xml)
     didl_object = data_structures.DidlObject.from_element(elt)
     assert didl_object.title == "the_title"
     assert didl_object.parent_id == "pid"
     assert didl_object.item_id == "iid"
     assert didl_object.creator == "a_creator"
     assert didl_object.desc == "DUMMY"
     assert didl_object.item_class == "object"
Example #21
0
    def get_soap_header(self):
        """ Generate the SOAP authentication header for the related service.

        This header contains all the necessary authentication details.

        Returns:
            (str): A string representation of the XML content of the SOAP
                header

        """

        # According to the SONOS SMAPI, this header must be sent with all
        # SOAP requests. Building this is an expensive operation (though
        # occasionally necessary), so f we have a cached value, return it
        if self._cached_soap_header is not None:
            return self._cached_soap_header
        music_service = self.music_service
        credentials_header = XML.Element(
            "credentials", {'xmlns': "http://www.sonos.com/Services/1.1"})
        device_id = XML.SubElement(credentials_header, 'deviceId')
        device_id.text = self._device_id
        device_provider = XML.SubElement(credentials_header, 'deviceProvider')
        device_provider.text = 'Sonos'
        if music_service.account.oa_device_id:
            # OAuth account credentials are present. We must use them to
            # authenticate.
            login_token = XML.Element('loginToken')
            token = XML.SubElement(login_token, 'token')
            token.text = music_service.account.oa_device_id
            key = XML.SubElement(login_token, 'key')
            key.text = music_service.account.key
            household_id = XML.SubElement(login_token, 'householdId')
            household_id.text = self._device.household_id
            credentials_header.append(login_token)

        # otherwise, perhaps use DeviceLink or UserId auth
        elif music_service.auth_type in ['DeviceLink', 'UserId']:
            # We need a session ID from Sonos
            session_id = self._device.musicServices.GetSessionId([
                ('ServiceId', music_service.service_id),
                ('Username', music_service.account.username)
            ])['SessionId']
            session_elt = XML.Element('sessionId')
            session_elt.text = session_id
            credentials_header.append(session_elt)

        # Anonymous auth. No need for anything further.
        self._cached_soap_header = XML.tostring(credentials_header)
        return self._cached_soap_header
Example #22
0
 def test_create_didl_resource_to_from_element(self, helpers):
     res = data_structures.DidlResource('a%20uri',
                                        'a:protocol:info:xx',
                                        bitrate=3)
     elt = res.to_element()
     assert helpers.compare_xml(
         elt,
         XML.fromstring(b'<res bitrate="3" '
                        b'protocolInfo="a:protocol:info:xx">a%20uri</res>'))
     assert data_structures.DidlResource.from_element(elt) == res
Example #23
0
    def call(self):
        """ Call the SOAP method on the server """

        headers, data = self.prepare()

        # Check log level before logging XML, since prettifying it is
        # expensive
        if _LOG.isEnabledFor(logging.DEBUG):
            _LOG.debug("Sending %s, %s", headers, prettify(data))

        response = requests.post(
            self.endpoint,
            headers=headers,
            data=data.encode('utf-8'),
            **self.request_args
        )
        _LOG.debug("Received %s, %s", response.headers, response.text)
        status = response.status_code
        if status == 200:
            # The response is good. Extract the Body
            tree = XML.fromstring(response.content)
            # Get the first child of the <Body> tag. NB There should only be
            # one if the RPC standard is followed.
            body = tree.find(
                "{http://schemas.xmlsoap.org/soap/envelope/}Body")[0]
            return body
        elif status == 500:
            # We probably have a SOAP Fault
            tree = XML.fromstring(response.content)
            fault = tree.find(
                './/{http://schemas.xmlsoap.org/soap/envelope/}Fault'
            )
            if fault is None:
                # Not a SOAP fault. Must be something else.
                response.raise_for_status()
            faultcode = fault.findtext("faultcode")
            faultstring = fault.findtext("faultstring")
            faultdetail = fault.find("detail")
            raise SoapFault(faultcode, faultstring, faultdetail)
        else:
            # Something else has gone wrong. Probably a network error. Let
            # Requests handle it
            response.raise_for_status()
Example #24
0
    def get_soap_header(self):
        if self._session_id is None:
            return ''

        if self._cached_soap_header is not None:
            return self._cached_soap_header

        credentials_header = XML.Element(
            "credentials", {'xmlns': "http://www.sonos.com/Services/1.1"})

        if self.music_service.auth_type in ['UserId']:
            session_elt = XML.Element('sessionId')
            session_elt.text = self._session_id
            credentials_header.append(session_elt)
        else:
            raise Exception('unknown auth_type = ' +
                            self.music_service.auth_type)

        self._cached_soap_header = XML.tostring(
            credentials_header, encoding='utf-8').decode(encoding='utf-8')
        return self._cached_soap_header
Example #25
0
    def call(self):
        """ Call the SOAP method on the server """

        headers, data = self.prepare()

        # Check log level before logging XML, since prettifying it is
        # expensive
        if _LOG.isEnabledFor(logging.DEBUG):
            _LOG.debug("Sending %s, %s", headers, prettify(data))

        response = requests.post(self.endpoint,
                                 headers=headers,
                                 data=data.encode('utf-8'),
                                 **self.request_args)
        _LOG.debug("Received %s, %s", response.headers, response.text)
        status = response.status_code
        if status == 200:
            # The response is good. Extract the Body
            tree = XML.fromstring(response.content)
            # Get the first child of the <Body> tag. NB There should only be
            # one if the RPC standard is followed.
            body = tree.find(
                "{http://schemas.xmlsoap.org/soap/envelope/}Body")[0]
            return body
        elif status == 500:
            # We probably have a SOAP Fault
            tree = XML.fromstring(response.content)
            fault = tree.find(
                './/{http://schemas.xmlsoap.org/soap/envelope/}Fault')
            if fault is None:
                # Not a SOAP fault. Must be something else.
                response.raise_for_status()
            faultcode = fault.findtext("faultcode")
            faultstring = fault.findtext("faultstring")
            faultdetail = fault.find("detail")
            raise SoapFault(faultcode, faultstring, faultdetail)
        else:
            # Something else has gone wrong. Probably a network error. Let
            # Requests handle it
            response.raise_for_status()
Example #26
0
    def __init__(self, faultcode, faultstring, detail=None):
        """ Args:
                faultcode (str): The SOAP faultcode
                faultstring (str): The SOAP faultstring
                detail (Element): The SOAP fault detail, as an ElementTree
                     Element. Default, None

        """
        self.faultcode = faultcode
        self.faultstring = faultstring
        self.detail = detail
        self.detail_string = XML.tostring(detail) if detail is not None else ''
        super(SoapFault, self).__init__(faultcode, faultstring)
Example #27
0
    def __init__(self, faultcode, faultstring, detail=None):
        """ Args:
                faultcode (str): The SOAP faultcode
                faultstring (str): The SOAP faultstring
                detail (Element): The SOAP fault detail, as an ElementTree
                     Element. Default, None

        """
        self.faultcode = faultcode
        self.faultstring = faultstring
        self.detail = detail
        self.detail_string = XML.tostring(detail) if detail is not None else ''
        super(SoapFault, self).__init__(faultcode, faultstring)
Example #28
0
    def test_add_item_to_sonos_playlist(self, moco):
        playlist = mock.Mock()
        playlist.item_id = 7

        track = mock.Mock()
        track.uri = 'fake_uri'
        track.didl_metadata = XML.Element('a')

        update_id = 100
        moco._music_lib_search = mock.Mock(return_value=(
            {'UpdateID': update_id},
            None))

        moco.add_item_to_sonos_playlist(track, playlist)
        moco._music_lib_search.assert_called_once_with(playlist.item_id, 0, 1)
        moco.avTransport.AddURIToSavedQueue.assert_called_once_with(
            [('InstanceID', 0),
             ('UpdateID', update_id),
             ('ObjectID', playlist.item_id),
             ('EnqueuedURI', track.uri),
             ('EnqueuedURIMetaData', XML.tostring(track.didl_metadata)),
             ('AddAtIndex', 4294967295)]
        )
Example #29
0
    def get_soap_header(self):
        """ Generate the SOAP authentication header for the related service.

        This header contains all the necessary authentication details.

        Returns:
            (str): A string representation of the XML content of the SOAP
                header

        """

        # According to the SONOS SMAPI, this header must be sent with all
        # SOAP requests. Building this is an expensive operation (though
        # occasionally necessary), so f we have a cached value, return it
        if self._cached_soap_header is not None:
            return self._cached_soap_header
        music_service = self.music_service
        credentials_header = XML.Element(
            "credentials", {'xmlns': "http://www.sonos.com/Services/1.1"})
        device_id = XML.SubElement(credentials_header, 'deviceId')
        device_id.text = self._device_id
        device_provider = XML.SubElement(credentials_header, 'deviceProvider')
        device_provider.text = 'Sonos'
        if music_service.account.oa_device_id:
            # OAuth account credentials are present. We must use them to
            # authenticate.
            login_token = XML.Element('loginToken')
            token = XML.SubElement(login_token, 'token')
            token.text = music_service.account.oa_device_id
            key = XML.SubElement(login_token, 'key')
            key.text = music_service.account.key
            household_id = XML.SubElement(login_token, 'householdId')
            household_id.text = self._device.household_id
            credentials_header.append(login_token)

        # otherwise, perhaps use DeviceLink or UserId auth
        elif music_service.auth_type in ['DeviceLink', 'UserId']:
            # We need a session ID from Sonos
            session_id = self._device.musicServices.GetSessionId([
                ('ServiceId', music_service.service_id),
                ('Username', music_service.account.username)
            ])['SessionId']
            session_elt = XML.Element('sessionId')
            session_elt.text = session_id
            credentials_header.append(session_elt)

        # Anonymous auth. No need for anything further.
        self._cached_soap_header = XML.tostring(credentials_header)
        return self._cached_soap_header
Example #30
0
    def _get_search_prefix_map(self):
        """Fetch and parse the service search category mapping.

        Standard Sonos search categories are 'all', 'artists', 'albums',
        'tracks', 'playlists', 'genres', 'stations', 'tags'. Not all are
        available for each music service

        """
        # TuneIn does not have a pmap. Its search keys are is search:station,
        # search:show, search:host

        # Presentation maps can also define custom categories. See eg
        # http://sonos-pmap.ws.sonos.com/hypemachine_pmap.6.xml
        # <SearchCategories>
        # ...
        #     <CustomCategory mappedId="SBLG" stringId="Blogs"/>
        # </SearchCategories>
        # Is it already cached? If so, return it
        if self._search_prefix_map is not None:
            return self._search_prefix_map
        # Not cached. Fetch and parse presentation map
        self._search_prefix_map = {}
        # Tunein is a special case. It has no pmap, but supports searching
        if self.service_name == "TuneIn":
            self._search_prefix_map = {
                'stations': 'search:station',
                'shows': 'search:show',
                'hosts': 'search:host',
            }
            return self._search_prefix_map
        if self.presentation_map_uri is None:
            # Assume not searchable?
            return self._search_prefix_map
        log.info('Fetching presentation map from %s',
                 self.presentation_map_uri)
        pmap = requests.get(self.presentation_map_uri, timeout=9)
        pmap_root = XML.fromstring(pmap.content)
        # Search translations can appear in Category or CustomCategory elements
        categories = pmap_root.findall(".//SearchCategories/Category")
        if categories is None:
            return self._search_prefix_map
        for cat in categories:
            self._search_prefix_map[cat.get('id')] = cat.get('mappedId')
        custom_categories = pmap_root.findall(
            ".//SearchCategories/CustomCategory")
        for cat in custom_categories:
            self._search_prefix_map[cat.get('stringId')] = cat.get('mappedId')
        return self._search_prefix_map
Example #31
0
    def _get_search_prefix_map(self):
        """Fetch and parse the service search category mapping.

        Standard Sonos search categories are 'all', 'artists', 'albums',
        'tracks', 'playlists', 'genres', 'stations', 'tags'. Not all are
        available for each music service

        """
        # TuneIn does not have a pmap. Its search keys are is search:station,
        # search:show, search:host

        # Presentation maps can also define custom categories. See eg
        # http://sonos-pmap.ws.sonos.com/hypemachine_pmap.6.xml
        # <SearchCategories>
        # ...
        #     <CustomCategory mappedId="SBLG" stringId="Blogs"/>
        # </SearchCategories>
        # Is it already cached? If so, return it
        if self._search_prefix_map is not None:
            return self._search_prefix_map
        # Not cached. Fetch and parse presentation map
        self._search_prefix_map = {}
        # Tunein is a special case. It has no pmap, but supports searching
        if self.service_name == "TuneIn":
            self._search_prefix_map = {
                'stations': 'search:station',
                'shows': 'search:show',
                'hosts': 'search:host',
            }
            return self._search_prefix_map
        if self.presentation_map_uri is None:
            # Assume not searchable?
            return self._search_prefix_map
        log.info('Fetching presentation map from %s',
                 self.presentation_map_uri)
        pmap = requests.get(self.presentation_map_uri, timeout=9)
        pmap_root = XML.fromstring(pmap.content)
        # Search translations can appear in Category or CustomCategory elements
        categories = pmap_root.findall(".//SearchCategories/Category")
        if categories is None:
            return self._search_prefix_map
        for cat in categories:
            self._search_prefix_map[cat.get('id')] = cat.get('mappedId')
        custom_categories = pmap_root.findall(
            ".//SearchCategories/CustomCategory")
        for cat in custom_categories:
            self._search_prefix_map[cat.get('stringId')] = cat.get('mappedId')
        return self._search_prefix_map
Example #32
0
 def test_didl_object_from_wrong_class(self):
     # mismatched upnp class
     bad_elt1 = XML.fromstring(\
     """<item xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
           xmlns:dc="http://purl.org/dc/elements/1.1/"
           xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"
           id="iid" parentID="pid" restricted="true">
              <dc:title>the_title</dc:title>
              <upnp:class>object.item</upnp:class>
              <dc:creator>a_creator</dc:creator>
              <desc id="cdudn"
                nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">
                RINCON_AssociatedZPUDN
              </desc>
            </item>
     """)
     with pytest.raises(DIDLMetadataError) as excinfo:
         didl_object = data_structures.DidlObject.from_element(bad_elt1)
     assert ("UPnP class is incorrect. Expected 'object', got 'object.item'"
          ) in str(excinfo.value)
Example #33
0
 def test_didl_object_from_wrong_class(self):
     # mismatched upnp class
     bad_elt1 = XML.fromstring(
         """<item xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
           xmlns:dc="http://purl.org/dc/elements/1.1/"
           xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"
           id="iid" parentID="pid" restricted="true">
              <dc:title>the_title</dc:title>
              <upnp:class>object.item</upnp:class>
              <dc:creator>a_creator</dc:creator>
              <desc id="cdudn"
                nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">
                RINCON_AssociatedZPUDN
              </desc>
            </item>
     """)
     with pytest.raises(DIDLMetadataError) as excinfo:
         didl_object = data_structures.DidlObject.from_element(bad_elt1)
     assert ("UPnP class is incorrect. Expected 'object', got 'object.item'"
             ) in str(excinfo.value)
Example #34
0
    def test_didl_object_from_element(self):
        elt = XML.fromstring(
            """<item xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"
              xmlns:dc="http://purl.org/dc/elements/1.1/"
              xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"
              id="iid" parentID="pid" restricted="true">
                 <dc:title>the_title</dc:title>
                 <upnp:class>object</upnp:class>
                 <dc:creator>a_creator</dc:creator>
                 <desc id="cdudn"
                   nameSpace="urn:schemas-rinconnetworks-com:metadata-1-0/">DUMMY</desc>
               </item>
        """)

        didl_object = data_structures.DidlObject.from_element(elt)
        assert didl_object.title == 'the_title'
        assert didl_object.parent_id == 'pid'
        assert didl_object.item_id == 'iid'
        assert didl_object.creator == 'a_creator'
        assert didl_object.desc == 'DUMMY'
Example #35
0
    def test_add_item_to_sonos_playlist(self, moco):
        playlist = mock.Mock()
        playlist.item_id = 7

        track = mock.Mock()
        track.uri = 'fake_uri'
        track.didl_metadata = XML.Element('a')

        update_id = 100
        moco._music_lib_search = mock.Mock(return_value=(
            {'UpdateID': update_id},
            None))

        moco.add_item_to_sonos_playlist(track, playlist)
        moco._music_lib_search.assert_called_once_with(playlist.item_id, 0, 1)
        moco.avTransport.AddURIToSavedQueue.assert_called_once_with(
            [('InstanceID', 0),
             ('UpdateID', update_id),
             ('ObjectID', playlist.item_id),
             ('EnqueuedURI', track.uri),
             ('EnqueuedURIMetaData', XML.tostring(track.didl_metadata)),
             ('AddAtIndex', 4294967295)]
        )
Example #36
0
    def test_add_item_to_sonos_playlist(self, moco):
        moco.contentDirectory.reset_mock()

        playlist = mock.Mock()
        playlist.item_id = 7

        track = mock.Mock()
        track.uri = 'fake_uri'
        track.didl_metadata = XML.Element('a')

        update_id = 100

        moco.contentDirectory.Browse.return_value = {
            'NumberReturned': '0',
            'Result': '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"></DIDL-Lite>',
            'TotalMatches': '0',
            'UpdateID': update_id
        }

        moco.add_item_to_sonos_playlist(track, playlist)
        moco.contentDirectory.Browse.assert_called_once_with([
            ('ObjectID', playlist.item_id),
            ('BrowseFlag', 'BrowseDirectChildren'), 
            ('Filter', '*'),
            ('StartingIndex', 0),
            ('RequestedCount', 1),
            ('SortCriteria', '')
        ])
        moco.avTransport.AddURIToSavedQueue.assert_called_once_with(
            [('InstanceID', 0),
             ('UpdateID', update_id),
             ('ObjectID', playlist.item_id),
             ('EnqueuedURI', track.uri),
             ('EnqueuedURIMetaData', XML.tostring(track.didl_metadata)),
             ('AddAtIndex', 4294967295)]
        )
Example #37
0
    def call(self, method, args=None):
        """ Call a method on the server

        Args:
            method (str): The name of the method to call.
             args (list): A list of (parameter, value) pairs representing
                 the parameters of the method. Default None.

        Returns:
            (OrderedDict): An OrderedDict representing the response

        Raises:
            MusicServiceException

        """
        message = SoapMessage(
            endpoint=self.endpoint,
            method=method,
            parameters=[] if args is None else args,
            http_headers=self.http_headers,
            soap_action="http://www.sonos.com/Services/1"
                        ".1#{0}".format(method),
            soap_header=self.get_soap_header(),
            namespace=self.namespace,
            timeout=self.timeout)

        try:
            result_elt = message.call()
        except SoapFault as exc:
            if 'Client.TokenRefreshRequired' in exc.faultcode:
                log.debug('Token refresh required. Trying again')
                # Remove any cached value for the SOAP header
                self._cached_soap_header = None

                # <detail>
                #   <refreshAuthTokenResult>
                #       <authToken>xxxxxxx</authToken>
                #       <privateKey>zzzzzz</privateKey>
                #   </refreshAuthTokenResult>
                # </detail>
                auth_token = exc.detail.findtext('.//authToken')
                private_key = exc.detail.findtext('.//privateKey')
                # We have new details - update the account
                self.music_service.account.oa_device_id = auth_token
                self.music_service.account.key = private_key
                message = SoapMessage(
                    endpoint=self.endpoint,
                    method=method,
                    parameters=args,
                    http_headers=self.http_headers,
                    soap_action="http://www.sonos.com/Services/1"
                                ".1#{0}".format(method),
                    soap_header=self.get_soap_header(),
                    namespace=self.namespace,
                    timeout=self.timeout)
                result_elt = message.call()

            else:
                raise MusicServiceException(exc.faultstring, exc.faultcode)

        # The top key in the OrderedDict will be the methodResult. Its
        # value may be None if no results were returned.
        result = parse(
            XML.tostring(result_elt), process_namespaces=True,
            namespaces={'http://www.sonos.com/Services/1.1': None}
        ).values()[0]

        return result if result is not None else {}
Example #38
0
    def call(self, method, args=None):
        """ Call a method on the server

        Args:
            method (str): The name of the method to call.
             args (list): A list of (parameter, value) pairs representing
                 the parameters of the method. Default None.

        Returns:
            (OrderedDict): An OrderedDict representing the response

        Raises:
            MusicServiceException

        """
        message = SoapMessage(endpoint=self.endpoint,
                              method=method,
                              parameters=[] if args is None else args,
                              http_headers=self.http_headers,
                              soap_action="http://www.sonos.com/Services/1"
                              ".1#{0}".format(method),
                              soap_header=self.get_soap_header(),
                              namespace=self.namespace,
                              timeout=self.timeout)

        try:
            result_elt = message.call()
        except SoapFault as exc:
            if 'Client.TokenRefreshRequired' in exc.faultcode:
                log.debug('Token refresh required. Trying again')
                # Remove any cached value for the SOAP header
                self._cached_soap_header = None

                # <detail>
                #   <refreshAuthTokenResult>
                #       <authToken>xxxxxxx</authToken>
                #       <privateKey>zzzzzz</privateKey>
                #   </refreshAuthTokenResult>
                # </detail>
                auth_token = exc.detail.findtext('.//authToken')
                private_key = exc.detail.findtext('.//privateKey')
                # We have new details - update the account
                self.music_service.account.oa_device_id = auth_token
                self.music_service.account.key = private_key
                message = SoapMessage(
                    endpoint=self.endpoint,
                    method=method,
                    parameters=args,
                    http_headers=self.http_headers,
                    soap_action="http://www.sonos.com/Services/1"
                    ".1#{0}".format(method),
                    soap_header=self.get_soap_header(),
                    namespace=self.namespace,
                    timeout=self.timeout)
                result_elt = message.call()

            else:
                raise MusicServiceException(exc.faultstring, exc.faultcode)

        # The top key in the OrderedDict will be the methodResult. Its
        # value may be None if no results were returned.
        result = parse(XML.tostring(result_elt),
                       process_namespaces=True,
                       namespaces={
                           'http://www.sonos.com/Services/1.1': None
                       }).values()[0]

        return result if result is not None else {}
Example #39
0
    def get_accounts(cls, soco=None):
        """Get a dict containing all accounts known to the Sonos system.

        Args:
            soco (SoCo, optional): a SoCo instance to query. If None, a random
            instance is used. Defaults to None

        Returns:
            dict: A dict containing account instances. Each key is the
            account's serial number, and each value is the related Account
            instance. Accounts which have been marked as deleted are excluded.

        Note:
            Although an Account for TuneIn is always present, it is handled
            specially by Sonos, and will not appear in the returned dict. Any
            existing Account instance will have its attributes updated to
            those currently stored on the Sonos system.

        """

        root = XML.fromstring(cls._get_account_xml(soco))
        # _get_account_xml returns an ElementTree element like this:

        # <ZPSupportInfo type="User">
        #   <Accounts
        #   LastUpdateDevice="RINCON_000XXXXXXXX400"
        #   Version="8" NextSerialNum="5">
        #     <Account Type="2311" SerialNum="1">
        #         <UN>12345678</UN>
        #         <MD>1</MD>
        #         <NN></NN>
        #         <OADevID></OADevID>
        #         <Key></Key>
        #     </Account>
        #     <Account Type="41735" SerialNum="3" Deleted="1">
        #         <UN></UN>
        #         <MD>1</MD>
        #         <NN>Nickname</NN>
        #         <OADevID></OADevID>
        #         <Key></Key>
        #     </Account>
        # ...
        #   <Accounts />

        # pylint: disable=protected-access

        xml_accounts = root.findall('.//Account')
        result = {}
        for xml_account in xml_accounts:
            serial_number = xml_account.get('SerialNum')
            is_deleted = True if xml_account.get('Deleted') == '1' else False
            # cls._all_accounts is a weakvaluedict keyed by serial number.
            # We use it as a database to store details of the accounts we
            # know about. We need to update it with info obtained from the
            # XML just obtained, so (1) check to see if we already have an
            # entry in cls._all_accounts for the account we have found in
            # XML; (2) if so, delete it if the XML says it has been deleted;
            # and (3) if not, create an entry for it
            if cls._all_accounts.get(serial_number):
                # We have an existing entry in our database. Do we need to
                # delete it?
                if is_deleted:
                    # Yes, so delete it and move to the next XML account
                    del cls._all_accounts[serial_number]
                    continue
                else:
                    # No, so load up its details, ready to update them
                    account = cls._all_accounts.get(serial_number)
            else:
                # We have no existing entry for this account
                if is_deleted:
                    # but it is marked as deleted, so we don't need one
                    continue
                # If it is not marked as deleted, we need to create an entry
                account = Account()
                account.serial_number = serial_number
                cls._all_accounts[serial_number] = account

            # Now, update the entry in our database with the details from XML
            account.service_type = xml_account.get('Type')
            account.deleted = is_deleted
            account.username = xml_account.findtext('UN')
            # Not sure what 'MD' stands for.  Metadata?
            account.metadata = xml_account.findtext('MD')
            account.nickname = xml_account.findtext('NN')
            account.oa_device_id = xml_account.findtext('OADevID')
            account.key = xml_account.findtext('Key')

            result[serial_number] = account
        return result
Example #40
0
    def _get_music_services_data(cls):
        """Parse raw account data xml into a useful python datastructure.

        Returns:
            (dict): A dict. Each key is a service_type, and each value is a
                dict containing relevant data

        """
        # Return from cache if we have it.
        if cls._music_services_data is not None:
            return cls._music_services_data

        result = {}
        root = XML.fromstring(
            cls._get_music_services_data_xml().encode('utf-8'))
        # <Services SchemaVersion="1">
        #     <Service Id="163" Name="Spreaker" Version="1.1"
        #         Uri="http://sonos.spreaker.com/sonos/service/v1"
        #         SecureUri="https://sonos.spreaker.com/sonos/service/v1"
        #         ContainerType="MService"
        #         Capabilities="513"
        #         MaxMessagingChars="0">
        #         <Policy Auth="Anonymous" PollInterval="30" />
        #         <Presentation>
        #             <Strings
        #                 Version="1"
        #                 Uri="https:...string_table.xml" />
        #             <PresentationMap Version="2"
        #                 Uri="https://...presentation_map.xml" />
        #         </Presentation>
        #     </Service>
        # ...
        # </ Services>

        # Ideally, the search path should be './/Service' to find Service
        # elements at any level, but Python 2.6 breaks with this if Service
        # is a child of the current element. Since 'Service' works here, we use
        # that instead
        services = root.findall('Service')
        for service in services:
            result_value = service.attrib.copy()
            name = service.get('Name')
            result_value['Name'] = name
            auth_element = (service.find('Policy'))
            auth = auth_element.attrib
            result_value.update(auth)
            presentation_element = (service.find('.//PresentationMap'))
            if presentation_element is not None:
                result_value['PresentationMapUri'] = \
                    presentation_element.get('Uri')
            result_value['ServiceID'] = service.get('Id')
            # ServiceType is used elsewhere in Sonos, eg to form tokens,
            # and get_subscribed_music_services() below. It is also the
            # 'Type' used in account_xml (see above). Its value always
            # seems to be (ID*256) + 7. Some serviceTypes are also
            # listed in available_services['AvailableServiceTypeList']
            # but this does not seem to be comprehensive
            service_type = str(int(service.get('Id')) * 256 + 7)
            result_value['ServiceType'] = service_type
            result[service_type] = result_value
        # Cache this so we don't need to do it again.
        cls._music_services_data = result
        return result
Example #41
0
 def test_zone_group_parser(self):
   tree = XML.fromstring(zone_group_xml().encode('utf-8'))
   zone_groups = parser.parse_zone_group_state(tree)
   self.assertEqual(len(zone_groups), 2)
   ids = [z.uid for z in zone_groups]
   self.assertCountEqual(["RINCON_000XXXX1400:0", "RINCON_000XXX1400:46"], ids)
Example #42
0
    def _get_music_services_data(cls):
        """Parse raw account data xml into a useful python datastructure.

        Returns:
            (dict): A dict. Each key is a service_type, and each value is a
                dict containing relevant data

        """
        # Return from cache if we have it.
        if cls._music_services_data is not None:
            return cls._music_services_data

        result = {}
        root = XML.fromstring(
            cls._get_music_services_data_xml().encode('utf-8')
        )
        # <Services SchemaVersion="1">
        #     <Service Id="163" Name="Spreaker" Version="1.1"
        #         Uri="http://sonos.spreaker.com/sonos/service/v1"
        #         SecureUri="https://sonos.spreaker.com/sonos/service/v1"
        #         ContainerType="MService"
        #         Capabilities="513"
        #         MaxMessagingChars="0">
        #         <Policy Auth="Anonymous" PollInterval="30" />
        #         <Presentation>
        #             <Strings
        #                 Version="1"
        #                 Uri="https:...string_table.xml" />
        #             <PresentationMap Version="2"
        #                 Uri="https://...presentation_map.xml" />
        #         </Presentation>
        #     </Service>
        # ...
        # </ Services>

        # Ideally, the search path should be './/Service' to find Service
        # elements at any level, but Python 2.6 breaks with this if Service
        # is a child of the current element. Since 'Service' works here, we use
        # that instead
        services = root.findall('Service')
        for service in services:
            result_value = service.attrib.copy()
            name = service.get('Name')
            result_value['Name'] = name
            auth_element = (service.find('Policy'))
            auth = auth_element.attrib
            result_value.update(auth)
            presentation_element = (service.find('.//PresentationMap'))
            if presentation_element is not None:
                result_value['PresentationMapUri'] = \
                    presentation_element.get('Uri')
            result_value['ServiceID'] = service.get('Id')
            # ServiceType is used elsewhere in Sonos, eg to form tokens,
            # and get_subscribed_music_services() below. It is also the
            # 'Type' used in account_xml (see above). Its value always
            # seems to be (ID*256) + 7. Some serviceTypes are also
            # listed in available_services['AvailableServiceTypeList']
            # but this does not seem to be comprehensive
            service_type = str(int(service.get('Id')) * 256 + 7)
            result_value['ServiceType'] = service_type
            result[service_type] = result_value
        # Cache this so we don't need to do it again.
        cls._music_services_data = result
        return result
Example #43
0
    def get_accounts(cls, soco=None):
        """Get a dict containing all accounts known to the Sonos system.

        Args:
            soco (SoCo, optional): a SoCo instance to query. If None, a random
            instance is used. Defaults to None

        Returns:
            dict: A dict containing account instances. Each key is the
            account's serial number, and each value is the related Account
            instance. Accounts which have been marked as deleted are excluded.

        Note:
            Although an Account for TuneIn is always present, it is handled
            specially by Sonos, and will not appear in the returned dict. Any
            existing Account instance will have its attributes updated to
            those currently stored on the Sonos system.

        """

        root = XML.fromstring(cls._get_account_xml(soco))
        # _get_account_xml returns an ElementTree element like this:

        # <ZPSupportInfo type="User">
        #   <Accounts
        #   LastUpdateDevice="RINCON_000XXXXXXXX400"
        #   Version="8" NextSerialNum="5">
        #     <Account Type="2311" SerialNum="1">
        #         <UN>12345678</UN>
        #         <MD>1</MD>
        #         <NN></NN>
        #         <OADevID></OADevID>
        #         <Key></Key>
        #     </Account>
        #     <Account Type="41735" SerialNum="3" Deleted="1">
        #         <UN></UN>
        #         <MD>1</MD>
        #         <NN>Nickname</NN>
        #         <OADevID></OADevID>
        #         <Key></Key>
        #     </Account>
        # ...
        #   <Accounts />

        # pylint: disable=protected-access

        xml_accounts = root.findall('.//Account')
        result = {}
        for xml_account in xml_accounts:
            serial_number = xml_account.get('SerialNum')
            is_deleted = True if xml_account.get('Deleted') == '1' else False
            # cls._all_accounts is a weakvaluedict keyed by serial number.
            # We use it as a database to store details of the accounts we
            # know about. We need to update it with info obtained from the
            # XML just obtained, so (1) check to see if we already have an
            # entry in cls._all_accounts for the account we have found in
            # XML; (2) if so, delete it if the XML says it has been deleted;
            # and (3) if not, create an entry for it
            if cls._all_accounts.get(serial_number):
                # We have an existing entry in our database. Do we need to
                # delete it?
                if is_deleted:
                    # Yes, so delete it and move to the next XML account
                    del cls._all_accounts[serial_number]
                    continue
                else:
                    # No, so load up its details, ready to update them
                    account = cls._all_accounts.get(serial_number)
            else:
                # We have no existing entry for this account
                if is_deleted:
                    # but it is marked as deleted, so we don't need one
                    continue
                # If it is not marked as deleted, we need to create an entry
                account = Account()
                account.serial_number = serial_number
                cls._all_accounts[serial_number] = account

            # Now, update the entry in our database with the details from XML
            account.service_type = xml_account.get('Type')
            account.deleted = is_deleted
            account.username = xml_account.findtext('UN')
            # Not sure what 'MD' stands for.  Metadata?
            account.metadata = xml_account.findtext('MD')
            account.nickname = xml_account.findtext('NN')
            account.oa_device_id = xml_account.findtext('OADevID')
            account.key = xml_account.findtext('Key')

            result[serial_number] = account
        return result