1# pylint: disable=fixme, protected-access
2"""The core module contains the SoCo class that implements
3the main entry to the SoCo functionality
4"""
5
6
7import datetime
8import logging
9import re
10import socket
11from functools import wraps
12import urllib.parse
13from xml.sax.saxutils import escape
14from xml.parsers.expat import ExpatError
15import warnings
16import xmltodict
17
18import requests
19from requests.exceptions import ConnectionError as RequestsConnectionError
20from requests.exceptions import ConnectTimeout, ReadTimeout
21
22from . import config
23from .data_structures import (
24    DidlObject,
25    DidlPlaylistContainer,
26    DidlResource,
27    Queue,
28    to_didl_string,
29)
30from .cache import Cache
31from .data_structures_entry import from_didl_string
32from .exceptions import (
33    SoCoSlaveException,
34    SoCoUPnPException,
35    NotSupportedException,
36    SoCoNotVisibleException,
37)
38from .groups import ZoneGroup
39from .music_library import MusicLibrary
40from .services import (
41    DeviceProperties,
42    ContentDirectory,
43    RenderingControl,
44    AVTransport,
45    ZoneGroupTopology,
46    AlarmClock,
47    SystemProperties,
48    MusicServices,
49    AudioIn,
50    GroupRenderingControl,
51)
52from .utils import really_utf8, camel_to_underscore, deprecated
53from .xml import XML
54
55AUDIO_INPUT_FORMATS = {
56    0: "No input connected",
57    2: "Stereo",
58    7: "Dolby 2.0",
59    18: "Dolby 5.1",
60    21: "No input",
61    22: "No audio",
62    59: "Dolby Atmos",
63    63: "Dolby Atmos",
64    33554434: "PCM 2.0",
65    33554454: "PCM 2.0 no audio",
66    33554488: "Dolby 2.0",
67    33554490: "Dolby Digital Plus 2.0",
68    33554494: "Dolby Multichannel PCM 2.0",
69    84934658: "Multichannel PCM 5.1",
70    84934713: "Dolby 5.1",
71    84934714: "Dolby Digital Plus 5.1",
72}
73
74_LOG = logging.getLogger(__name__)
75
76
77class _ArgsSingleton(type):
78
79    """A metaclass which permits only a single instance of each derived class
80    sharing the same `_class_group` class attribute to exist for any given set
81    of positional arguments.
82
83    Attempts to instantiate a second instance of a derived class, or another
84    class with the same `_class_group`, with the same args will return the
85    existing instance.
86
87    For example:
88
89    >>> class ArgsSingletonBase(object):
90    ...     __metaclass__ = _ArgsSingleton
91    ...
92    >>> class First(ArgsSingletonBase):
93    ...     _class_group = "greeting"
94    ...     def __init__(self, param):
95    ...         pass
96    ...
97    >>> class Second(ArgsSingletonBase):
98    ...     _class_group = "greeting"
99    ...     def __init__(self, param):
100    ...         pass
101    >>> assert First('hi') is First('hi')
102    >>> assert First('hi') is First('bye')
103    AssertionError
104    >>> assert First('hi') is Second('hi')
105    """
106
107    _instances = {}
108
109    def __call__(cls, *args, **kwargs):
110        key = cls._class_group if hasattr(cls, "_class_group") else cls
111        if key not in cls._instances:
112            cls._instances[key] = {}
113        if args not in cls._instances[key]:
114            cls._instances[key][args] = super().__call__(*args, **kwargs)
115        return cls._instances[key][args]
116
117
118class _SocoSingletonBase(  # pylint: disable=too-few-public-methods,no-init
119    _ArgsSingleton("ArgsSingletonMeta", (object,), {})
120):
121
122    """The base class for the SoCo class.
123
124    Uses a Python 2 and 3 compatible method of declaring a metaclass. See, eg,
125    here: http://www.artima.com/weblogs/viewpost.jsp?thread=236234 and
126    here: http://mikewatkins.ca/2008/11/29/python-2-and-3-metaclasses/
127    """
128
129
130def only_on_master(function):
131    """Decorator that raises SoCoSlaveException on master call on slave."""
132
133    @wraps(function)
134    def inner_function(self, *args, **kwargs):
135        """Master checking inner function."""
136        if not self.is_coordinator:
137            message = (
138                'The method or property "{}" can only be called/used '
139                "on the coordinator in a group".format(function.__name__)
140            )
141            raise SoCoSlaveException(message)
142        return function(self, *args, **kwargs)
143
144    return inner_function
145
146
147# pylint: disable=R0904,too-many-instance-attributes
148class SoCo(_SocoSingletonBase):
149
150    """A simple class for controlling a Sonos speaker.
151
152    For any given set of arguments to __init__, only one instance of this class
153    may be created. Subsequent attempts to create an instance with the same
154    arguments will return the previously created instance. This means that all
155    SoCo instances created with the same ip address are in fact the *same* SoCo
156    instance, reflecting the real world position.
157
158    ..  rubric:: Basic Methods
159    ..  autosummary::
160
161        play_from_queue
162        play
163        play_uri
164        pause
165        stop
166        end_direct_control_session
167        seek
168        next
169        previous
170        mute
171        volume
172        play_mode
173        shuffle
174        repeat
175        cross_fade
176        ramp_to_volume
177        set_relative_volume
178        get_current_track_info
179        get_current_media_info
180        get_speaker_info
181        get_current_transport_info
182
183    ..  rubric:: Queue Management
184    ..  autosummary::
185
186        get_queue
187        queue_size
188        add_to_queue
189        add_uri_to_queue
190        add_multiple_to_queue
191        remove_from_queue
192        clear_queue
193
194    ..  rubric:: Group Management
195    ..  autosummary::
196
197        group
198        partymode
199        join
200        unjoin
201        all_groups
202        all_zones
203        visible_zones
204
205    ..  rubric:: Player Identity and Settings
206    ..  autosummary::
207
208        player_name
209        uid
210        household_id
211        is_visible
212        is_bridge
213        is_coordinator
214        is_soundbar
215        surround_enabled
216        is_satellite
217        has_satellites
218        sub_enabled
219        is_subwoofer
220        has_subwoofer
221        channel
222        bass
223        treble
224        loudness
225        balance
226        night_mode
227        dialog_mode
228        supports_fixed_volume
229        fixed_volume
230        soundbar_audio_input_format
231        soundbar_audio_input_format_code
232        trueplay
233        status_light
234        buttons_enabled
235
236    ..  rubric:: Playlists and Favorites
237    ..  autosummary::
238
239        get_sonos_playlists
240        create_sonos_playlist
241        create_sonos_playlist_from_queue
242        remove_sonos_playlist
243        add_item_to_sonos_playlist
244        reorder_sonos_playlist
245        clear_sonos_playlist
246        move_in_sonos_playlist
247        remove_from_sonos_playlist
248        get_sonos_playlist_by_attr
249        get_favorite_radio_shows
250        get_favorite_radio_stations
251        get_sonos_favorites
252
253    ..  rubric:: Miscellaneous
254    ..  autosummary::
255
256        music_source
257        music_source_from_uri
258        is_playing_radio
259        is_playing_tv
260        is_playing_line_in
261        switch_to_line_in
262        switch_to_tv
263        available_actions
264        set_sleep_timer
265        get_sleep_timer
266        create_stereo_pair
267        separate_stereo_pair
268        get_battery_info
269        boot_seqnum
270
271    .. warning::
272
273        Properties on this object are not generally cached and may obtain
274        information over the network, so may take longer than expected to set
275        or return a value. It may be a good idea for you to cache the value in
276        your own code.
277
278    .. note::
279
280        Since all methods/properties on this object will result in an UPnP
281        request, they might result in an exception without it being mentioned
282        in the Raises section.
283
284        In most cases, the exception will be a
285        :class:`soco.exceptions.SoCoUPnPException`
286        (if the player returns an UPnP error code), but in special cases
287        it might also be another :class:`soco.exceptions.SoCoException`
288        or even a `requests` exception.
289
290    """
291
292    _class_group = "SoCo"
293
294    # pylint: disable=super-on-old-class
295    def __init__(self, ip_address):
296        # Note: Creation of a SoCo instance should be as cheap and quick as
297        # possible. Do not make any network calls here
298        super().__init__()
299        # Check if ip_address is a valid IPv4 representation.
300        # Sonos does not (yet) support IPv6
301        try:
302            socket.inet_aton(ip_address)
303        except OSError as error:
304            raise ValueError("Not a valid IP address string") from error
305        #: The speaker's ip address
306        self.ip_address = ip_address
307        self.speaker_info = {}  # Stores information about the current speaker
308
309        # The services which we use
310        # pylint: disable=invalid-name
311        self.avTransport = AVTransport(self)
312        self.contentDirectory = ContentDirectory(self)
313        self.deviceProperties = DeviceProperties(self)
314        self.renderingControl = RenderingControl(self)
315        self.groupRenderingControl = GroupRenderingControl(self)
316        self.zoneGroupTopology = ZoneGroupTopology(self)
317        self.alarmClock = AlarmClock(self)
318        self.systemProperties = SystemProperties(self)
319        self.musicServices = MusicServices(self)
320        self.audioIn = AudioIn(self)
321
322        self.music_library = MusicLibrary(self)
323
324        # Some private attributes
325        self._all_zones = set()
326        self._boot_seqnum = None
327        self._groups = set()
328        self._channel_map = None
329        self._ht_sat_chan_map = None
330        self._is_bridge = None
331        self._is_coordinator = False
332        self._is_satellite = False
333        self._has_satellites = False
334        self._channel = None
335        self._is_soundbar = None
336        self._player_name = None
337        self._uid = None
338        self._household_id = None
339        self._visible_zones = set()
340        self._zgs_cache = Cache(default_timeout=5)
341        self._zgs_result = None
342
343        _LOG.debug("Created SoCo instance for ip: %s", ip_address)
344
345    def __str__(self):
346        return "<{} object at ip {}>".format(self.__class__.__name__, self.ip_address)
347
348    def __repr__(self):
349        return '{}("{}")'.format(self.__class__.__name__, self.ip_address)
350
351    @property
352    def boot_seqnum(self):
353        """int: The boot sequence number."""
354        self._parse_zone_group_state()
355        return int(self._boot_seqnum)
356
357    @property
358    def player_name(self):
359        """str: The speaker's name."""
360        # We could get the name like this:
361        # result = self.deviceProperties.GetZoneAttributes()
362        # return result["CurrentZoneName"]
363        # but it is probably quicker to get it from the group topology
364        # and take advantage of any caching
365        self._parse_zone_group_state()
366        return self._player_name
367
368    @player_name.setter
369    def player_name(self, playername):
370        """Set the speaker's name."""
371        self.deviceProperties.SetZoneAttributes(
372            [
373                ("DesiredZoneName", playername),
374                ("DesiredIcon", ""),
375                ("DesiredConfiguration", ""),
376            ]
377        )
378
379    @property
380    def uid(self):
381        """str: A unique identifier.
382
383        Looks like: ``'RINCON_000XXXXXXXXXX1400'``
384        """
385        # Since this does not change over time (?) check whether we already
386        # know the answer. If so, there is no need to go further
387        if self._uid is not None:
388            return self._uid
389        # if not, we have to get it from the zone topology, which
390        # is probably quicker than any alternative, since the zgt is probably
391        # cached. This will set self._uid for us for next time, so we won't
392        # have to do this again
393        self._parse_zone_group_state()
394        return self._uid
395        # An alternative way of getting the uid is as follows:
396        # self.device_description_url = \
397        #    'http://{0}:1400/xml/device_description.xml'.format(
398        #     self.ip_address)
399        # response = requests.get(self.device_description_url).text
400        # tree = XML.fromstring(response.encode('utf-8'))
401        # udn = tree.findtext('.//{urn:schemas-upnp-org:device-1-0}UDN')
402        # # the udn has a "uuid:" prefix before the uid, so we need to strip it
403        # self._uid = uid = udn[5:]
404        # return uid
405
406    @property
407    def household_id(self):
408        """str: A unique identifier for all players in a household.
409
410        Looks like: ``'Sonos_asahHKgjgJGjgjGjggjJgjJG34'``
411        """
412        # Since this does not change over time (?) check whether we already
413        # know the answer. If so, return the cached version
414        if self._household_id is None:
415            self._household_id = self.deviceProperties.GetHouseholdID()[
416                "CurrentHouseholdID"
417            ]
418        return self._household_id
419
420    @property
421    def is_visible(self):
422        """bool: Is this zone visible?
423
424        A zone might be invisible if, for example, it is a bridge, or the slave
425        part of stereo pair.
426        """
427        # We could do this:
428        # invisible = self.deviceProperties.GetInvisible()['CurrentInvisible']
429        # but it is better to do it in the following way, which uses the
430        # zone group topology, to capitalise on any caching.
431        return self in self.visible_zones
432
433    @property
434    def is_bridge(self):
435        """bool: Is this zone a bridge?"""
436        # Since this does not change over time (?) check whether we already
437        # know the answer. If so, there is no need to go further
438        if self._is_bridge is not None:
439            return self._is_bridge
440        # if not, we have to get it from the zone topology. This will set
441        # self._is_bridge for us for next time, so we won't have to do this
442        # again
443        self._parse_zone_group_state()
444        return self._is_bridge
445
446    @property
447    def is_coordinator(self):
448        """bool: Is this zone a group coordinator?"""
449        # We could do this:
450        # invisible = self.deviceProperties.GetInvisible()['CurrentInvisible']
451        # but it is better to do it in the following way, which uses the
452        # zone group topology, to capitalise on any caching.
453        self._parse_zone_group_state()
454        return self._is_coordinator
455
456    @property
457    def is_satellite(self):
458        """bool: Is this zone a satellite in a home theater setup?"""
459        self._parse_zone_group_state()
460        return self._is_satellite
461
462    @property
463    def has_satellites(self):
464        """bool: Is this zone configured with satellites in a home theater setup?
465
466        Will only return True on the primary device in a home theater configuration.
467        """
468        self._parse_zone_group_state()
469        return self._has_satellites
470
471    @property
472    def is_subwoofer(self):
473        """bool: Is this zone a subwoofer?"""
474        if self.channel == "SW":
475            return True
476        return False
477
478    @property
479    def has_subwoofer(self):
480        """bool: Is this zone configured with a subwoofer?
481
482        Only provides reliable results when called on the soundbar
483        or subwoofer devices if configured in a home theater setup.
484        """
485        self._parse_zone_group_state()
486        channel_map = self._channel_map or self._ht_sat_chan_map
487        if not channel_map:
488            return False
489
490        if ":SW" in channel_map:  # pylint: disable=E1135
491            return True
492        return False
493
494    @property
495    def channel(self):
496        """str: Location of this zone in a home theater or paired configuration.
497
498        Can be one of "LF,RF", "LF", "RF", "LR", "RR", "SW", or None.
499        """
500        self._parse_zone_group_state()
501        # Omit repeated channel entries (e.g., "RF,RF" -> "RF")
502        if self._channel:
503            channels = set(self._channel.split(","))
504            if len(channels) == 1:
505                return channels.pop()
506        return self._channel
507
508    @property
509    def is_soundbar(self):
510        """bool: Is this zone a soundbar (i.e. has night mode etc.)?"""
511        if self._is_soundbar is None:
512            if not self.speaker_info:
513                self.get_speaker_info()
514
515            model_name = self.speaker_info["model_name"].lower()
516            self._is_soundbar = any(model_name.endswith(s) for s in SOUNDBARS)
517
518        return self._is_soundbar
519
520    @property
521    def play_mode(self):
522        """str: The queue's play mode.
523
524        Case-insensitive options are:
525
526        *   ``'NORMAL'`` -- Turns off shuffle and repeat.
527        *   ``'REPEAT_ALL'`` -- Turns on repeat and turns off shuffle.
528        *   ``'SHUFFLE'`` -- Turns on shuffle *and* repeat. (It's
529            strange, I know.)
530        *   ``'SHUFFLE_NOREPEAT'`` -- Turns on shuffle and turns off
531            repeat.
532        *   ``'REPEAT_ONE'`` -- Turns on repeat one and turns off shuffle.
533        *   ``'SHUFFLE_REPEAT_ONE'`` -- Turns on shuffle *and* repeat one. (It's
534            strange, I know.)
535
536        """
537        result = self.avTransport.GetTransportSettings(
538            [
539                ("InstanceID", 0),
540            ]
541        )
542        return result["PlayMode"]
543
544    @play_mode.setter
545    def play_mode(self, playmode):
546        """Set the speaker's mode."""
547        playmode = playmode.upper()
548        if playmode not in PLAY_MODES:
549            raise KeyError("'%s' is not a valid play mode" % playmode)
550
551        self.avTransport.SetPlayMode([("InstanceID", 0), ("NewPlayMode", playmode)])
552
553    @property
554    def shuffle(self):
555        """bool: The queue's shuffle option.
556
557        True if enabled, False otherwise.
558        """
559        return PLAY_MODES[self.play_mode][0]
560
561    @shuffle.setter
562    def shuffle(self, shuffle):
563        """Set the queue's shuffle option."""
564        repeat = self.repeat
565        self.play_mode = PLAY_MODE_BY_MEANING[(shuffle, repeat)]
566
567    @property
568    def repeat(self):
569        """bool: The queue's repeat option.
570
571        True if enabled, False otherwise.
572
573        Can also be the string ``'ONE'`` for play mode
574        ``'REPEAT_ONE'``.
575        """
576        return PLAY_MODES[self.play_mode][1]
577
578    @repeat.setter
579    def repeat(self, repeat):
580        """Set the queue's repeat option"""
581        shuffle = self.shuffle
582        self.play_mode = PLAY_MODE_BY_MEANING[(shuffle, repeat)]
583
584    @property
585    @only_on_master  # Only for symmetry with the setter
586    def cross_fade(self):
587        """bool: The speaker's cross fade state.
588
589        True if enabled, False otherwise
590        """
591
592        response = self.avTransport.GetCrossfadeMode(
593            [
594                ("InstanceID", 0),
595            ]
596        )
597        cross_fade_state = response["CrossfadeMode"]
598        return bool(int(cross_fade_state))
599
600    @cross_fade.setter
601    @only_on_master
602    def cross_fade(self, crossfade):
603        """Set the speaker's cross fade state."""
604        crossfade_value = "1" if crossfade else "0"
605        self.avTransport.SetCrossfadeMode(
606            [("InstanceID", 0), ("CrossfadeMode", crossfade_value)]
607        )
608
609    def ramp_to_volume(self, volume, ramp_type="SLEEP_TIMER_RAMP_TYPE"):
610        """Smoothly change the volume.
611
612        There are three ramp types available:
613
614            * ``'SLEEP_TIMER_RAMP_TYPE'`` (default): Linear ramp from the
615              current volume up or down to the new volume. The ramp rate is
616              1.25 steps per second. For example: To change from volume 50 to
617              volume 30 would take 16 seconds.
618            * ``'ALARM_RAMP_TYPE'``: Resets the volume to zero, waits for about
619              30 seconds, and then ramps the volume up to the desired value at
620              a rate of 2.5 steps per second. For example: Volume 30 would take
621              12 seconds for the ramp up (not considering the wait time).
622            * ``'AUTOPLAY_RAMP_TYPE'``: Resets the volume to zero and then
623              quickly ramps up at a rate of 50 steps per second. For example:
624              Volume 30 will take only 0.6 seconds.
625
626        The ramp rate is selected by Sonos based on the chosen ramp type and
627        the resulting transition time returned.
628        This method is non blocking and has no network overhead once sent.
629
630        Args:
631            volume (int): The new volume.
632            ramp_type (str, optional): The desired ramp type, as described
633                above.
634
635        Returns:
636            int: The ramp time in seconds, rounded down. Note that this does
637            not include the wait time.
638        """
639        response = self.renderingControl.RampToVolume(
640            [
641                ("InstanceID", 0),
642                ("Channel", "Master"),
643                ("RampType", ramp_type),
644                ("DesiredVolume", volume),
645                ("ResetVolumeAfter", False),
646                ("ProgramURI", ""),
647            ]
648        )
649        return int(response["RampTime"])
650
651    def set_relative_volume(self, relative_volume):
652        """Adjust the volume up or down by a relative amount.
653
654        If the adjustment causes the volume to overshoot the maximum value
655        of 100, the volume will be set to 100. If the adjustment causes the
656        volume to undershoot the minimum value of 0, the volume will be set
657        to 0.
658
659        Note that this method is an alternative to using addition and
660        subtraction assignment operators (+=, -=) on the `volume` property
661        of a `SoCo` instance. These operators perform the same function as
662        `set_relative_volume` but require two network calls per operation
663        instead of one.
664
665        Args:
666            relative_volume (int): The relative volume adjustment. Can be
667                positive or negative.
668
669        Returns:
670            int: The new volume setting.
671
672        Raises:
673            ValueError: If ``relative_volume`` cannot be cast as an integer.
674        """
675        relative_volume = int(relative_volume)
676        # Sonos will automatically handle out-of-range adjustments
677        response = self.renderingControl.SetRelativeVolume(
678            [("InstanceID", 0), ("Channel", "Master"), ("Adjustment", relative_volume)]
679        )
680        return int(response["NewVolume"])
681
682    @only_on_master
683    def play_from_queue(self, index, start=True):
684        """Play a track from the queue by index.
685
686        The index number is required as an argument, where the first index
687        is 0.
688
689        Args:
690            index (int): 0-based index of the track to play
691            start (bool): If the item that has been set should start playing
692        """
693        # Grab the speaker's information if we haven't already since we'll need
694        # it in the next step.
695        if not self.speaker_info:
696            self.get_speaker_info()
697
698        # first, set the queue itself as the source URI
699        uri = "x-rincon-queue:{}#0".format(self.uid)
700        self.avTransport.SetAVTransportURI(
701            [("InstanceID", 0), ("CurrentURI", uri), ("CurrentURIMetaData", "")]
702        )
703
704        # second, set the track number with a seek command
705        self.avTransport.Seek(
706            [("InstanceID", 0), ("Unit", "TRACK_NR"), ("Target", index + 1)]
707        )
708
709        # finally, just play what's set if needed
710        if start:
711            self.play()
712
713    @only_on_master
714    def play(self):
715        """Play the currently selected track."""
716        self.avTransport.Play([("InstanceID", 0), ("Speed", 1)])
717
718    @only_on_master
719    # pylint: disable=too-many-arguments
720    def play_uri(self, uri="", meta="", title="", start=True, force_radio=False):
721        """Play a URI.
722
723        Playing a URI will replace what was playing with the stream
724        given by the URI. For some streams at least a title is
725        required as metadata.  This can be provided using the ``meta``
726        argument or the ``title`` argument.  If the ``title`` argument
727        is provided minimal metadata will be generated.  If ``meta``
728        argument is provided the ``title`` argument is ignored.
729
730        Args:
731            uri (str): URI of the stream to be played.
732            meta (str): The metadata to show in the player, DIDL format.
733            title (str): The title to show in the player (if no meta).
734            start (bool): If the URI that has been set should start playing.
735            force_radio (bool): forces a uri to play as a radio stream.
736
737        On a Sonos controller music is shown with one of the following display
738        formats and controls:
739
740        * Radio format: Shows the name of the radio station and other available
741          data. No seek, next, previous, or voting capability.
742          Examples: TuneIn, radioPup
743        * Smart Radio:  Shows track name, artist, and album. Limited seek, next
744          and sometimes voting capability depending on the Music Service.
745          Examples: Amazon Prime Stations, Pandora Radio Stations.
746        * Track format: Shows track name, artist, and album the same as when
747          playing from a queue. Full seek, next and previous capabilities.
748          Examples: Spotify, Napster, Rhapsody.
749
750        How it is displayed is determined by the URI prefix:
751        ``x-sonosapi-stream:``, ``x-sonosapi-radio:``,
752        ``x-rincon-mp3radio:``, ``hls-radio:`` default to radio or
753        smart radio format depending on the stream. Others default to
754        track format: ``x-file-cifs:``, ``aac:``, ``http:``,
755        ``https:``, ``x-sonos-spotify:`` (used by Spotify),
756        ``x-sonosapi-hls-static:`` (Amazon Prime), ``x-sonos-http:``
757        (Google Play & Napster).
758
759        Some URIs that default to track format could be radio streams,
760        typically ``http:``, ``https:`` or ``aac:``.  To force display
761        and controls to Radio format set ``force_radio=True``
762
763        .. note:: Other URI prefixes exist but are less common.
764           If you have information on these please add to this doc string.
765
766        .. note:: A change in Sonos® (as of at least version 6.4.2)
767           means that the devices no longer accepts ordinary ``http:``
768           and ``https:`` URIs for radio stations. This method has the
769           option to replaces these prefixes with the one that Sonos®
770           expects: ``x-rincon-mp3radio:`` by using the
771           "force_radio=True" parameter.  A few streams may fail if
772           not forced to to Radio format.
773
774        """
775        if meta == "" and title != "":
776            meta_template = (
777                '<DIDL-Lite xmlns:dc="http://purl.org/dc/elements'
778                '/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/" '
779                'xmlns:r="urn:schemas-rinconnetworks-com:metadata-1-0/" '
780                'xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/">'
781                '<item id="R:0/0/0" parentID="R:0/0" restricted="true">'
782                "<dc:title>{title}</dc:title><upnp:class>"
783                "object.item.audioItem.audioBroadcast</upnp:class><desc "
784                'id="cdudn" nameSpace="urn:schemas-rinconnetworks-com:'
785                'metadata-1-0/">{service}</desc></item></DIDL-Lite>'
786            )
787            tunein_service = "SA_RINCON65031_"
788            # Radio stations need to have at least a title to play
789            meta = meta_template.format(title=escape(title), service=tunein_service)
790
791        # change uri prefix to force radio style display and commands
792        if force_radio:
793            colon = uri.find(":")
794            if colon > 0:
795                uri = "x-rincon-mp3radio{}".format(uri[colon:])
796
797        self.avTransport.SetAVTransportURI(
798            [("InstanceID", 0), ("CurrentURI", uri), ("CurrentURIMetaData", meta)]
799        )
800        # The track is enqueued, now play it if needed
801        if start:
802            return self.play()
803        return False
804
805    @only_on_master
806    def pause(self):
807        """Pause the currently playing track."""
808        self.avTransport.Pause([("InstanceID", 0), ("Speed", 1)])
809
810    @only_on_master
811    def stop(self):
812        """Stop the currently playing track."""
813        self.avTransport.Stop([("InstanceID", 0), ("Speed", 1)])
814
815    @only_on_master
816    def end_direct_control_session(self):
817        """Ends all third-party controlled streaming sessions."""
818        self.avTransport.EndDirectControlSession([("InstanceID", 0)])
819
820    @only_on_master
821    def seek(self, position=None, track=None):
822        """Seek to a given position.
823
824        You can seek both a relative position in the current track and a track
825        number in the queue.
826        It is even possible to seek to a tuple or dict containing the absolute
827        position (relative pos. and track nr.)::
828
829            t = ('0:00:00', 0)
830            player.seek(*t)
831            d = {'position': '0:00:00', 'track': 0}
832            player.seek(**d)
833
834        Args:
835            position (str): The desired timestamp in the current track,
836                specified in the format of HH:MM:SS or H:MM:SS
837            track (int): The (zero-based) track index in the queue
838
839        Raises:
840            ValueError: If neither position nor track are specified.
841            SoCoUPnPException: UPnP Error 701 if seeking is not supported,
842                UPnP Error 711 if the target is invalid.
843
844        Note:
845            The 'track' parameter can only be used if the queue is currently
846            playing. If not, use :py:meth:`play_from_queue`.
847
848        This is currently faster than :py:meth:`play_from_queue` if already
849        using the queue, as it does not reinstate the queue.
850
851        If speaker is already playing it will continue to play after
852        seek. If paused it will remain paused.
853        """
854
855        if track is None and position is None:
856            raise ValueError("No position or track information given")
857
858        if track is not None:
859            self.avTransport.Seek(
860                [("InstanceID", 0), ("Unit", "TRACK_NR"), ("Target", track + 1)]
861            )
862
863        if position is not None:
864            if not re.match(r"^[0-9][0-9]?:[0-9][0-9]:[0-9][0-9]$", position):
865                raise ValueError("invalid timestamp, use HH:MM:SS format")
866
867            self.avTransport.Seek(
868                [("InstanceID", 0), ("Unit", "REL_TIME"), ("Target", position)]
869            )
870
871    @only_on_master
872    def next(self):
873        """Go to the next track.
874
875        Keep in mind that next() can return errors
876        for a variety of reasons. For example, if the Sonos is streaming
877        Pandora and you call next() several times in quick succession an error
878        code will likely be returned (since Pandora has limits on how many
879        songs can be skipped).
880        """
881        self.avTransport.Next([("InstanceID", 0), ("Speed", 1)])
882
883    @only_on_master
884    def previous(self):
885        """Go back to the previously played track.
886
887        Keep in mind that previous() can return errors
888        for a variety of reasons. For example, previous() will return an error
889        code (error code 701) if the Sonos is streaming Pandora since you can't
890        go back on tracks.
891        """
892        self.avTransport.Previous([("InstanceID", 0), ("Speed", 1)])
893
894    @property
895    def mute(self):
896        """bool: The speaker's mute state.
897
898        True if muted, False otherwise.
899        """
900
901        response = self.renderingControl.GetMute(
902            [("InstanceID", 0), ("Channel", "Master")]
903        )
904        mute_state = response["CurrentMute"]
905        return bool(int(mute_state))
906
907    @mute.setter
908    def mute(self, mute):
909        """Mute (or unmute) the speaker."""
910        mute_value = "1" if mute else "0"
911        self.renderingControl.SetMute(
912            [("InstanceID", 0), ("Channel", "Master"), ("DesiredMute", mute_value)]
913        )
914
915    @property
916    def volume(self):
917        """int: The speaker's volume.
918
919        An integer between 0 and 100.
920        """
921
922        response = self.renderingControl.GetVolume(
923            [
924                ("InstanceID", 0),
925                ("Channel", "Master"),
926            ]
927        )
928        volume = response["CurrentVolume"]
929        return int(volume)
930
931    @volume.setter
932    def volume(self, volume):
933        """Set the speaker's volume."""
934        volume = int(volume)
935        volume = max(0, min(volume, 100))  # Coerce in range
936        self.renderingControl.SetVolume(
937            [("InstanceID", 0), ("Channel", "Master"), ("DesiredVolume", volume)]
938        )
939
940    @property
941    def bass(self):
942        """int: The speaker's bass EQ.
943
944        An integer between -10 and 10.
945        """
946
947        response = self.renderingControl.GetBass(
948            [
949                ("InstanceID", 0),
950                ("Channel", "Master"),
951            ]
952        )
953        bass = response["CurrentBass"]
954        return int(bass)
955
956    @bass.setter
957    def bass(self, bass):
958        """Set the speaker's bass."""
959        bass = int(bass)
960        bass = max(-10, min(bass, 10))  # Coerce in range
961        self.renderingControl.SetBass([("InstanceID", 0), ("DesiredBass", bass)])
962
963    @property
964    def treble(self):
965        """int: The speaker's treble EQ.
966
967        An integer between -10 and 10.
968        """
969
970        response = self.renderingControl.GetTreble(
971            [
972                ("InstanceID", 0),
973                ("Channel", "Master"),
974            ]
975        )
976        treble = response["CurrentTreble"]
977        return int(treble)
978
979    @treble.setter
980    def treble(self, treble):
981        """Set the speaker's treble."""
982        treble = int(treble)
983        treble = max(-10, min(treble, 10))  # Coerce in range
984        self.renderingControl.SetTreble([("InstanceID", 0), ("DesiredTreble", treble)])
985
986    @property
987    def loudness(self):
988        """bool: The speaker's loudness compensation.
989
990        True if on, False otherwise.
991
992        Loudness is a complicated topic. You can read about it on
993        Wikipedia: https://en.wikipedia.org/wiki/Loudness
994
995        """
996        response = self.renderingControl.GetLoudness(
997            [
998                ("InstanceID", 0),
999                ("Channel", "Master"),
1000            ]
1001        )
1002        loudness = response["CurrentLoudness"]
1003        return bool(int(loudness))
1004
1005    @loudness.setter
1006    def loudness(self, loudness):
1007        """Switch on/off the speaker's loudness compensation."""
1008        loudness_value = "1" if loudness else "0"
1009        self.renderingControl.SetLoudness(
1010            [
1011                ("InstanceID", 0),
1012                ("Channel", "Master"),
1013                ("DesiredLoudness", loudness_value),
1014            ]
1015        )
1016
1017    @property
1018    def surround_enabled(self):
1019        """bool: Reports if the home theater surround speakers are enabled.
1020
1021        Should only be called on the primary device in a home theater setup.
1022
1023        True if on, False if off, None if not supported.
1024        """
1025        if not self.is_soundbar:
1026            return None
1027
1028        response = self.renderingControl.GetEQ(
1029            [("InstanceID", 0), ("EQType", "SurroundEnable")]
1030        )
1031        return bool(int(response["CurrentValue"]))
1032
1033    @surround_enabled.setter
1034    def surround_enabled(self, enable):
1035        """Enable/disable the connected surround speakers.
1036
1037        :param enable: Enable or disable surround speakers
1038        :type enable: bool
1039        """
1040        if not self.is_soundbar:
1041            message = "This device does not support surrounds"
1042            raise NotSupportedException(message)
1043
1044        self.renderingControl.SetEQ(
1045            [
1046                ("InstanceID", 0),
1047                ("EQType", "SurroundEnable"),
1048                ("DesiredValue", int(enable)),
1049            ]
1050        )
1051
1052    @property
1053    def sub_enabled(self):
1054        """bool: Reports if the subwoofer is enabled.
1055
1056        True if on, False if off, None if not supported.
1057        """
1058        if not self.has_subwoofer:
1059            return None
1060
1061        response = self.renderingControl.GetEQ(
1062            [("InstanceID", 0), ("EQType", "SubEnable")]
1063        )
1064        return bool(int(response["CurrentValue"]))
1065
1066    @sub_enabled.setter
1067    def sub_enabled(self, enable):
1068        """Enable/disable the connected subwoofer.
1069
1070        :param enable: Enable or disable the subwoofer
1071        :type enable: bool
1072        """
1073        if not self.has_subwoofer:
1074            message = "This group does not have a subwoofer"
1075            raise NotSupportedException(message)
1076
1077        self.renderingControl.SetEQ(
1078            [
1079                ("InstanceID", 0),
1080                ("EQType", "SubEnable"),
1081                ("DesiredValue", int(enable)),
1082            ]
1083        )
1084
1085    @property
1086    def balance(self):
1087        """The left/right balance for the speaker(s).
1088
1089        Returns:
1090            tuple: A 2-tuple (left_channel, right_channel) of integers
1091            between 0 and 100, representing the volume of each channel.
1092            E.g., (100, 100) represents full volume to both channels,
1093            whereas (100, 0) represents left channel at full volume,
1094            right channel at zero volume.
1095        """
1096
1097        response_lf = self.renderingControl.GetVolume(
1098            [
1099                ("InstanceID", 0),
1100                ("Channel", "LF"),
1101            ]
1102        )
1103        response_rf = self.renderingControl.GetVolume(
1104            [
1105                ("InstanceID", 0),
1106                ("Channel", "RF"),
1107            ]
1108        )
1109        volume_lf = response_lf["CurrentVolume"]
1110        volume_rf = response_rf["CurrentVolume"]
1111        return int(volume_lf), int(volume_rf)
1112
1113    @balance.setter
1114    def balance(self, left_right_tuple):
1115        """Set the left/right balance for the speaker(s)."""
1116        left, right = left_right_tuple
1117        left = int(left)
1118        right = int(right)
1119        left = max(0, min(left, 100))  # Coerce in range
1120        right = max(0, min(right, 100))  # Coerce in range
1121        self.renderingControl.SetVolume(
1122            [("InstanceID", 0), ("Channel", "LF"), ("DesiredVolume", left)]
1123        )
1124        self.renderingControl.SetVolume(
1125            [("InstanceID", 0), ("Channel", "RF"), ("DesiredVolume", right)]
1126        )
1127
1128    @property
1129    def night_mode(self):
1130        """bool: The speaker's night mode.
1131
1132        True if on, False if off, None if not supported.
1133        """
1134        if not self.is_soundbar:
1135            return None
1136
1137        response = self.renderingControl.GetEQ(
1138            [("InstanceID", 0), ("EQType", "NightMode")]
1139        )
1140        return bool(int(response["CurrentValue"]))
1141
1142    @night_mode.setter
1143    def night_mode(self, night_mode):
1144        """Switch on/off the speaker's night mode.
1145
1146        :param night_mode: Enable or disable night mode
1147        :type night_mode: bool
1148        :raises NotSupportedException: If the device does not support
1149        night mode.
1150        """
1151        if not self.is_soundbar:
1152            message = "This device does not support night mode"
1153            raise NotSupportedException(message)
1154
1155        self.renderingControl.SetEQ(
1156            [
1157                ("InstanceID", 0),
1158                ("EQType", "NightMode"),
1159                ("DesiredValue", int(night_mode)),
1160            ]
1161        )
1162
1163    @property
1164    def dialog_mode(self):
1165        """bool: The speaker's dialog mode.
1166
1167        True if on, False if off, None if not supported.
1168        """
1169        if not self.is_soundbar:
1170            return None
1171
1172        response = self.renderingControl.GetEQ(
1173            [("InstanceID", 0), ("EQType", "DialogLevel")]
1174        )
1175        return bool(int(response["CurrentValue"]))
1176
1177    @dialog_mode.setter
1178    def dialog_mode(self, dialog_mode):
1179        """Switch on/off the speaker's dialog mode.
1180
1181        :param dialog_mode: Enable or disable dialog mode
1182        :type dialog_mode: bool
1183        :raises NotSupportedException: If the device does not support
1184        dialog mode.
1185        """
1186        if not self.is_soundbar:
1187            message = "This device does not support dialog mode"
1188            raise NotSupportedException(message)
1189
1190        self.renderingControl.SetEQ(
1191            [
1192                ("InstanceID", 0),
1193                ("EQType", "DialogLevel"),
1194                ("DesiredValue", int(dialog_mode)),
1195            ]
1196        )
1197
1198    @property
1199    def dialog_level(self):
1200        """Convenience wrapper for dialog_mode getter to match raw Sonos API."""
1201        return self.dialog_mode
1202
1203    @dialog_level.setter
1204    def dialog_level(self, dialog_level):
1205        """Convenience wrapper for dialog_mode setter to match raw Sonos API."""
1206        self.dialog_mode = dialog_level
1207
1208    @property
1209    def trueplay(self):
1210        """bool: Whether Trueplay is enabled on this device.
1211        True if on, False if off.
1212
1213        Devices that do not support Trueplay, or which do not have
1214        a current Trueplay calibration, will return `None` on getting
1215        the property, and  raise a `NotSupportedException` when
1216        setting the property.
1217
1218        Can only be set on visible devices. Attempting to set on non-visible
1219        devices will raise a `SoCoNotVisibleException`.
1220        """
1221        response = self.renderingControl.GetRoomCalibrationStatus([("InstanceID", 0)])
1222        if response["RoomCalibrationAvailable"] == "0":
1223            return None
1224        else:
1225            return response["RoomCalibrationEnabled"] == "1"
1226
1227    @trueplay.setter
1228    def trueplay(self, trueplay):
1229        """Toggle the device's TruePlay setting. Only available to
1230        Sonos speakers, not the Connect, Amp, etc., and only available to
1231        speakers that have a current Trueplay calibration.
1232
1233        :param trueplay: Enable or disable Trueplay.
1234        :type trueplay: bool
1235        :raises NotSupportedException: If the device does not support
1236        Trueplay or doesn't have a current calibration.
1237        :raises SoCoNotVisibleException: If the device is not visible.
1238        """
1239        response = self.renderingControl.GetRoomCalibrationStatus([("InstanceID", 0)])
1240        if response["RoomCalibrationAvailable"] == "0":
1241            raise NotSupportedException
1242
1243        if not self.is_visible:
1244            raise SoCoNotVisibleException
1245
1246        trueplay_value = "1" if trueplay else "0"
1247        self.renderingControl.SetRoomCalibrationStatus(
1248            [
1249                ("InstanceID", 0),
1250                ("RoomCalibrationEnabled", trueplay_value),
1251            ]
1252        )
1253
1254    @property
1255    def soundbar_audio_input_format_code(self):
1256        """Return audio input format code as reported by the device.
1257
1258        Returns None when the device is not a soundbar.
1259
1260        While the variable is available on non-soundbar devices,
1261        it is likely always 0 for devices without audio inputs.
1262
1263        See also :func:`soundbar_audio_input_format` for obtaining a
1264        human-readable description of the format.
1265        """
1266        if not self.is_soundbar:
1267            return None
1268
1269        response = self.deviceProperties.GetZoneInfo()
1270
1271        return int(response["HTAudioIn"])
1272
1273    @property
1274    def soundbar_audio_input_format(self):
1275        """Return a string presentation of the audio input format.
1276
1277        Returns None when the device is not a soundbar.
1278        Otherwise, this will return the string presentation of the currently
1279        active sound format (e.g., "Dolby 5.1" or "No input")
1280
1281        See also :func:`soundbar_audio_input_format_code` for the raw value.
1282        """
1283        if not self.is_soundbar:
1284            return None
1285
1286        format_code = self.soundbar_audio_input_format_code
1287
1288        if format_code not in AUDIO_INPUT_FORMATS:
1289            error_message = "Unknown audio input format: {}".format(format_code)
1290            logging.debug(error_message)
1291            return error_message
1292
1293        return AUDIO_INPUT_FORMATS[format_code]
1294
1295    @property
1296    def supports_fixed_volume(self):
1297        """bool: Whether the device supports fixed volume output."""
1298
1299        response = self.renderingControl.GetSupportsOutputFixed([("InstanceID", 0)])
1300        return response["CurrentSupportsFixed"] == "1"
1301
1302    @property
1303    def fixed_volume(self):
1304        """bool: The device's fixed volume output setting.
1305
1306        True if on, False if off. Only applicable to certain
1307        Sonos devices (Connect and Port at the time of writing).
1308        All other devices always return False.
1309
1310        Attempting to set this property for a non-applicable
1311        device will raise a `NotSupportedException`.
1312        """
1313
1314        response = self.renderingControl.GetOutputFixed([("InstanceID", 0)])
1315        return response["CurrentFixed"] == "1"
1316
1317    @fixed_volume.setter
1318    def fixed_volume(self, fixed_volume):
1319        """Switch on/off the device's fixed volume output setting.
1320
1321        Only applicable to certain Sonos devices.
1322
1323        :param fixed_volume: Enable or disable fixed volume output mode.
1324        :type fixed_volume: bool
1325        :raises NotSupportedException: If the device does not support
1326        fixed volume output mode.
1327        """
1328
1329        try:
1330            self.renderingControl.SetOutputFixed(
1331                [
1332                    ("InstanceID", 0),
1333                    ("DesiredFixed", "1" if fixed_volume else "0"),
1334                ]
1335            )
1336        except SoCoUPnPException as error:
1337            raise NotSupportedException from error
1338
1339    def _parse_zone_group_state(self):
1340        """The Zone Group State contains a lot of useful information.
1341
1342        Retrieve and parse it, and populate the relevant properties.
1343        """
1344
1345        # zoneGroupTopology.GetZoneGroupState()['ZoneGroupState'] returns XML like
1346        # this:
1347        #
1348        # <ZoneGroups>
1349        #   <ZoneGroup Coordinator="RINCON_000XXX1400" ID="RINCON_000XXXX1400:0">
1350        #     <ZoneGroupMember
1351        #         BootSeq="33"
1352        #         Configuration="1"
1353        #         Icon="x-rincon-roomicon:zoneextender"
1354        #         Invisible="1"
1355        #         IsZoneBridge="1"
1356        #         Location="http://192.168.1.100:1400/xml/device_description.xml"
1357        #         MinCompatibleVersion="22.0-00000"
1358        #         SoftwareVersion="24.1-74200"
1359        #         UUID="RINCON_000ZZZ1400"
1360        #         ZoneName="BRIDGE"/>
1361        #   </ZoneGroup>
1362        #   <ZoneGroup Coordinator="RINCON_000XXX1400" ID="RINCON_000XXX1400:46">
1363        #     <ZoneGroupMember
1364        #         BootSeq="44"
1365        #         Configuration="1"
1366        #         Icon="x-rincon-roomicon:living"
1367        #         Location="http://192.168.1.101:1400/xml/device_description.xml"
1368        #         MinCompatibleVersion="22.0-00000"
1369        #         SoftwareVersion="24.1-74200"
1370        #         UUID="RINCON_000XXX1400"
1371        #         ZoneName="Living Room"/>
1372        #     <ZoneGroupMember
1373        #         BootSeq="52"
1374        #         Configuration="1"
1375        #         Icon="x-rincon-roomicon:kitchen"
1376        #         Location="http://192.168.1.102:1400/xml/device_description.xml"
1377        #         MinCompatibleVersion="22.0-00000"
1378        #         SoftwareVersion="24.1-74200"
1379        #         UUID="RINCON_000YYY1400"
1380        #         ZoneName="Kitchen"/>
1381        #   </ZoneGroup>
1382        # </ZoneGroups>
1383        #
1384
1385        def parse_zone_group_member(member_element):
1386            """Parse a ZoneGroupMember or Satellite element from Zone Group
1387            State, create a SoCo instance for the member, set basic attributes
1388            and return it."""
1389            # Create a SoCo instance for each member. Because SoCo
1390            # instances are singletons, this is cheap if they have already
1391            # been created, and useful if they haven't. We can then
1392            # update various properties for that instance.
1393            member_attribs = member_element.attrib
1394            ip_addr = member_attribs["Location"].split("//")[1].split(":")[0]
1395            zone = config.SOCO_CLASS(ip_addr)
1396            # share our cache
1397            zone._zgs_cache = self._zgs_cache
1398            # uid doesn't change, but it's not harmful to (re)set it, in case
1399            # the zone is as yet unseen.
1400            zone._uid = member_attribs["UUID"]
1401            zone._player_name = member_attribs["ZoneName"]
1402            zone._boot_seqnum = member_attribs["BootSeq"]
1403            zone._channel_map = member_attribs.get("ChannelMapSet")
1404            zone._ht_sat_chan_map = member_attribs.get("HTSatChanMapSet")
1405            if zone._channel_map:
1406                for channel in zone._channel_map.split(";"):
1407                    if channel.startswith(zone._uid):
1408                        zone._channel = channel.split(":")[-1]
1409            if zone._ht_sat_chan_map:
1410                for channel in zone._ht_sat_chan_map.split(";"):
1411                    if channel.startswith(zone._uid):
1412                        zone._channel = channel.split(":")[-1]
1413            # add the zone to the set of all members, and to the set
1414            # of visible members if appropriate
1415            is_visible = member_attribs.get("Invisible") != "1"
1416            if is_visible:
1417                self._visible_zones.add(zone)
1418            self._all_zones.add(zone)
1419            return zone
1420
1421        # This is called quite frequently, so it is worth optimising it.
1422        # Maintain a private cache. If the zgt has not changed, there is no
1423        # need to repeat all the XML parsing. In addition, switch on network
1424        # caching for a short interval (5 secs).
1425        zgs = self.zoneGroupTopology.GetZoneGroupState(cache=self._zgs_cache)[
1426            "ZoneGroupState"
1427        ]
1428        if zgs == self._zgs_result:
1429            return
1430        self._zgs_result = zgs
1431        tree = XML.fromstring(zgs.encode("utf-8"))
1432        # Empty the set of all zone_groups
1433        self._groups.clear()
1434        # and the set of all members
1435        self._all_zones.clear()
1436        self._visible_zones.clear()
1437        # Compatibility fallback for pre-10.1 firmwares
1438        # where a "ZoneGroups" element is not used
1439        tree = tree.find("ZoneGroups") or tree
1440        # Loop over each ZoneGroup Element
1441        for group_element in tree.findall("ZoneGroup"):
1442            coordinator_uid = group_element.attrib["Coordinator"]
1443            group_uid = group_element.attrib["ID"]
1444            group_coordinator = None
1445            members = set()
1446            for member_element in group_element.findall("ZoneGroupMember"):
1447                zone = parse_zone_group_member(member_element)
1448                zone._is_satellite = False
1449                # Perform extra processing relevant to direct zone group
1450                # members
1451                #
1452                # If this element has the same UUID as the coordinator, it is
1453                # the coordinator
1454                if zone._uid == coordinator_uid:
1455                    group_coordinator = zone
1456                    zone._is_coordinator = True
1457                else:
1458                    zone._is_coordinator = False
1459                # is_bridge doesn't change, but it does no real harm to
1460                # set/reset it here, just in case the zone has not been seen
1461                # before
1462                zone._is_bridge = member_element.attrib.get("IsZoneBridge") == "1"
1463                # add the zone to the members for this group
1464                members.add(zone)
1465                # Loop over Satellite elements if present, and process as for
1466                # ZoneGroup elements
1467                satellite_elements = member_element.findall("Satellite")
1468                if satellite_elements:
1469                    zone._has_satellites = True
1470                else:
1471                    zone._has_satellites = False
1472                for satellite_element in satellite_elements:
1473                    zone = parse_zone_group_member(satellite_element)
1474                    zone._is_satellite = True
1475                    # Assume a satellite can't be a bridge or coordinator, so
1476                    # no need to check.
1477                    #
1478                    # Add the zone to the members for this group.
1479                    members.add(zone)
1480                # Now create a ZoneGroup with this info and add it to the list
1481                # of groups
1482            self._groups.add(ZoneGroup(group_uid, group_coordinator, members))
1483
1484    @property
1485    def all_groups(self):
1486        """set of :class:`soco.groups.ZoneGroup`: All available groups."""
1487        self._parse_zone_group_state()
1488        return self._groups.copy()
1489
1490    @property
1491    def group(self):
1492        """:class:`soco.groups.ZoneGroup`: The Zone Group of which this device
1493        is a member.
1494
1495        None if this zone is a slave in a stereo pair.
1496        """
1497
1498        for group in self.all_groups:
1499            if self in group:
1500                return group
1501        return None
1502
1503        # To get the group directly from the network, try the code below
1504        # though it is probably slower than that above
1505        # current_group_id = self.zoneGroupTopology.GetZoneGroupAttributes()[
1506        #     'CurrentZoneGroupID']
1507        # if current_group_id:
1508        #     for group in self.all_groups:
1509        #         if group.uid == current_group_id:
1510        #             return group
1511        # else:
1512        #     return None
1513
1514    @property
1515    def all_zones(self):
1516        """set of :class:`soco.groups.ZoneGroup`: All available zones."""
1517        self._parse_zone_group_state()
1518        return self._all_zones.copy()
1519
1520    @property
1521    def visible_zones(self):
1522        """set of :class:`soco.groups.ZoneGroup`: All visible zones."""
1523        self._parse_zone_group_state()
1524        return self._visible_zones.copy()
1525
1526    def partymode(self):
1527        """Put all the speakers in the network in the same group, a.k.a Party
1528        Mode.
1529
1530        This blog shows the initial research responsible for this:
1531        http://blog.travelmarx.com/2010/06/exploring-sonos-via-upnp.html
1532
1533        The trick seems to be (only tested on a two-speaker setup) to tell each
1534        speaker which to join. There's probably a bit more to it if multiple
1535        groups have been defined.
1536        """
1537        # Tell every other visible zone to join this one
1538        # pylint: disable = expression-not-assigned
1539        [zone.join(self) for zone in self.visible_zones if zone is not self]
1540
1541    def join(self, master):
1542        """Join this speaker to another "master" speaker."""
1543
1544        self.avTransport.SetAVTransportURI(
1545            [
1546                ("InstanceID", 0),
1547                ("CurrentURI", "x-rincon:{}".format(master.uid)),
1548                ("CurrentURIMetaData", ""),
1549            ]
1550        )
1551        self._zgs_cache.clear()
1552
1553    def unjoin(self):
1554        """Remove this speaker from a group.
1555
1556        Seems to work ok even if you remove what was previously the group
1557        master from it's own group. If the speaker was not in a group also
1558        returns ok.
1559        """
1560
1561        self.avTransport.BecomeCoordinatorOfStandaloneGroup([("InstanceID", 0)])
1562        self._zgs_cache.clear()
1563
1564    def create_stereo_pair(self, rh_slave_speaker):
1565        """Create a stereo pair.
1566
1567        This speaker becomes the master, left-hand speaker of the stereo
1568        pair. The ``rh_slave_speaker`` becomes the right-hand speaker.
1569        Note that this operation will succeed on dissimilar speakers, unlike
1570        when using the official Sonos apps.
1571
1572        Args:
1573            rh_slave_speaker (SoCo): The speaker that will be added as
1574                the right-hand, slave speaker of the stereo pair.
1575
1576        Raises:
1577            SoCoUPnPException: if either speaker is already part of a
1578                stereo pair.
1579        """
1580        # The pairing operation must be applied to the speaker that will
1581        # become the master (the left-hand speaker of the pair).
1582        # Note that if either speaker is part of a group, the call will
1583        # succeed.
1584        param = self.uid + ":LF,LF;" + rh_slave_speaker.uid + ":RF,RF"
1585        self.deviceProperties.AddBondedZones([("ChannelMapSet", param)])
1586
1587    def separate_stereo_pair(self):
1588        """Separate a stereo pair.
1589
1590        This can be called on either the master (left-hand) speaker, or on the
1591        slave (right-hand) speaker, to create two independent zones.
1592
1593        Raises:
1594            SoCoUPnPException: if the speaker is not a member of a stereo pair.
1595        """
1596        self.deviceProperties.RemoveBondedZones(
1597            [("ChannelMapSet", ""), ("KeepGrouped", "0")]
1598        )
1599
1600    def switch_to_line_in(self, source=None):
1601        """Switch the speaker's input to line-in.
1602
1603        Args:
1604            source (SoCo): The speaker whose line-in should be played.
1605                Default is line-in from the speaker itself.
1606        """
1607        if source:
1608            uid = source.uid
1609        else:
1610            uid = self.uid
1611
1612        self.avTransport.SetAVTransportURI(
1613            [
1614                ("InstanceID", 0),
1615                ("CurrentURI", "x-rincon-stream:{}".format(uid)),
1616                ("CurrentURIMetaData", ""),
1617            ]
1618        )
1619
1620    @property
1621    def is_playing_radio(self):
1622        """bool: Is the speaker playing radio?"""
1623        return self.music_source == MUSIC_SRC_RADIO
1624
1625    @property
1626    def is_playing_line_in(self):
1627        """bool: Is the speaker playing line-in?"""
1628        return self.music_source == MUSIC_SRC_LINE_IN
1629
1630    @property
1631    def is_playing_tv(self):
1632        """bool: Is the playbar speaker input from TV?"""
1633        return self.music_source == MUSIC_SRC_TV
1634
1635    @staticmethod
1636    def music_source_from_uri(uri):
1637        """Determine a music source from a URI.
1638
1639        Arguments:
1640            uri (str) : The URI representing the music source
1641
1642        Returns:
1643            str: The current source of music.
1644
1645        Possible return values are:
1646
1647        *   ``'NONE'`` -- speaker has no music to play.
1648        *   ``'LIBRARY'`` -- speaker is playing queued titles from the music
1649            library.
1650        *   ``'RADIO'`` -- speaker is playing radio.
1651        *   ``'WEB_FILE'`` -- speaker is playing a music file via http/https.
1652        *   ``'LINE_IN'`` -- speaker is playing music from line-in.
1653        *   ``'TV'`` -- speaker is playing input from TV.
1654        *   ``'AIRPLAY'`` -- speaker is playing from AirPlay.
1655        *   ``'UNKNOWN'`` -- any other input.
1656
1657        The strings above can be imported as ``MUSIC_SRC_LIBRARY``,
1658        ``MUSIC_SRC_RADIO``, etc.
1659        """
1660        for regex, source in SOURCES.items():
1661            if re.match(regex, uri) is not None:
1662                return source
1663        return MUSIC_SRC_UNKNOWN
1664
1665    @property
1666    def music_source(self):
1667        """str: The current music source (radio, TV, line-in, etc.).
1668
1669        Possible return values are the same as used in `music_source_from_uri()`.
1670        """
1671        response = self.avTransport.GetPositionInfo(
1672            [("InstanceID", 0), ("Channel", "Master")]
1673        )
1674        return self.music_source_from_uri(response["TrackURI"])
1675
1676    def switch_to_tv(self):
1677        """Switch the playbar speaker's input to TV."""
1678
1679        self.avTransport.SetAVTransportURI(
1680            [
1681                ("InstanceID", 0),
1682                ("CurrentURI", "x-sonos-htastream:{}:spdif".format(self.uid)),
1683                ("CurrentURIMetaData", ""),
1684            ]
1685        )
1686
1687    @property
1688    def status_light(self):
1689        """bool: The white Sonos status light between the mute button and the
1690        volume up button on the speaker.
1691
1692        True if on, otherwise False.
1693        """
1694        result = self.deviceProperties.GetLEDState()
1695        LEDState = result["CurrentLEDState"]  # pylint: disable=invalid-name
1696        return LEDState == "On"
1697
1698    @status_light.setter
1699    def status_light(self, led_on):
1700        """Switch on/off the speaker's status light."""
1701        led_state = "On" if led_on else "Off"
1702        self.deviceProperties.SetLEDState(
1703            [
1704                ("DesiredLEDState", led_state),
1705            ]
1706        )
1707
1708    @property
1709    def buttons_enabled(self):
1710        """bool: Whether the control buttons on the device are enabled.
1711
1712        `True` if the control buttons are enabled, `False` if disabled.
1713
1714        This property can only be set on visible speakers, and will enable
1715        or disable the buttons for all speakers in any bonded set (e.g., a
1716        stereo pair). Attempting to set it on invisible speakers
1717        (e.g., the RH speaker of a stereo pair) will raise a
1718        `SoCoNotVisibleException`.
1719        """
1720        lock_state = self.deviceProperties.GetButtonLockState()[
1721            "CurrentButtonLockState"
1722        ]
1723        return lock_state == "Off"
1724
1725    @buttons_enabled.setter
1726    def buttons_enabled(self, enabled):
1727        """Enable or disable the device's control buttons.
1728
1729        Args:
1730            bool: True to enable the buttons, False to disable.
1731
1732        Raises:
1733            SoCoNotVisibleException: If the speaker is not visible.
1734        """
1735        if not self.is_visible:
1736            raise SoCoNotVisibleException
1737        lock_state = "Off" if enabled else "On"
1738        self.deviceProperties.SetButtonLockState(
1739            [
1740                ("DesiredButtonLockState", lock_state),
1741            ]
1742        )
1743
1744    def get_current_track_info(self):
1745        """Get information about the currently playing track.
1746
1747        Returns:
1748            dict: A dictionary containing information about the currently
1749            playing track: playlist_position, duration, title, artist, album,
1750            position and an album_art link.
1751
1752        If we're unable to return data for a field, we'll return an empty
1753        string. This can happen for all kinds of reasons so be sure to check
1754        values. For example, a track may not have complete metadata and be
1755        missing an album name. In this case track['album'] will be an empty
1756        string.
1757
1758        .. note:: Calling this method on a slave in a group will not
1759            return the track the group is playing, but the last track
1760            this speaker was playing.
1761
1762        """
1763        response = self.avTransport.GetPositionInfo(
1764            [("InstanceID", 0), ("Channel", "Master")]
1765        )
1766
1767        track = {
1768            "title": "",
1769            "artist": "",
1770            "album": "",
1771            "album_art": "",
1772            "position": "",
1773        }
1774        track["playlist_position"] = response["Track"]
1775        track["duration"] = response["TrackDuration"]
1776        track["uri"] = response["TrackURI"]
1777        track["position"] = response["RelTime"]
1778
1779        metadata = response["TrackMetaData"]
1780        # Store the entire Metadata entry in the track, this can then be
1781        # used if needed by the client to restart a given URI
1782        track["metadata"] = metadata
1783
1784        def _parse_radio_metadata(metadata):
1785            """Try to parse trackinfo from radio metadata."""
1786            radio_track = {}
1787            trackinfo = (
1788                metadata.findtext(
1789                    ".//{urn:schemas-rinconnetworks-com:" "metadata-1-0/}streamContent"
1790                )
1791                or ""
1792            )
1793            index = trackinfo.find(" - ")
1794
1795            if index > -1:
1796                radio_track["artist"] = trackinfo[:index].strip()
1797                radio_track["title"] = trackinfo[index + 3 :].strip()
1798            elif "TYPE=SNG|" in trackinfo:
1799                # Examples from services:
1800                #  Apple Music radio:
1801                #   "TYPE=SNG|TITLE Couleurs|ARTIST M83|ALBUM Saturdays = Youth"
1802                #  SiriusXM:
1803                #   "BR P|TYPE=SNG|TITLE 7.15.17 LA|ARTIST Eagles|ALBUM "
1804                tags = dict([p.split(" ", 1) for p in trackinfo.split("|") if " " in p])
1805                if tags.get("TITLE"):
1806                    radio_track["title"] = tags["TITLE"]
1807                if tags.get("ARTIST"):
1808                    radio_track["artist"] = tags["ARTIST"]
1809                if tags.get("ALBUM"):
1810                    radio_track["album"] = tags["ALBUM"]
1811            else:
1812                # Might find some kind of title anyway in metadata
1813                title = metadata.findtext(
1814                    ".//{http://purl.org/dc/" "elements/1.1/}title"
1815                )
1816                # Avoid using URIs as the title
1817                if title in track["uri"] or title in urllib.parse.unquote(track["uri"]):
1818                    radio_track["title"] = trackinfo
1819                else:
1820                    radio_track["title"] = title
1821
1822            return radio_track
1823
1824        # Duration seems to be '0:00:00' when listening to radio
1825        if metadata != "" and track["duration"] == "0:00:00":
1826            metadata = XML.fromstring(really_utf8(metadata))
1827            track.update(_parse_radio_metadata(metadata))
1828
1829        # If the speaker is playing from the line-in source, querying for track
1830        # metadata will return "NOT_IMPLEMENTED".
1831        elif metadata not in ("", "NOT_IMPLEMENTED", None):
1832            # Track metadata is returned in DIDL-Lite format
1833            metadata = XML.fromstring(really_utf8(metadata))
1834            md_title = metadata.findtext(".//{http://purl.org/dc/elements/1.1/}title")
1835            md_artist = metadata.findtext(
1836                ".//{http://purl.org/dc/elements/1.1/}creator"
1837            )
1838            md_album = metadata.findtext(
1839                ".//{urn:schemas-upnp-org:metadata-1-0/upnp/}album"
1840            )
1841
1842            track["title"] = ""
1843            if md_title:
1844                track["title"] = md_title
1845            track["artist"] = ""
1846            if md_artist:
1847                track["artist"] = md_artist
1848            track["album"] = ""
1849            if md_album:
1850                track["album"] = md_album
1851
1852            album_art_url = metadata.findtext(
1853                ".//{urn:schemas-upnp-org:metadata-1-0/upnp/}albumArtURI"
1854            )
1855            if album_art_url is not None:
1856                track["album_art"] = self.music_library.build_album_art_full_uri(
1857                    album_art_url
1858                )
1859
1860        return track
1861
1862    def get_current_media_info(self):
1863        """Get information about the currently playing media.
1864
1865        Returns:
1866            dict: A dictionary containing information about the currently
1867            playing media: uri, channel.
1868
1869        If we're unable to return data for a field, we'll return an empty
1870        string.
1871        """
1872        response = self.avTransport.GetMediaInfo([("InstanceID", 0)])
1873        media = {"uri": "", "channel": ""}
1874
1875        media["uri"] = response["CurrentURI"]
1876
1877        metadata = response.get("CurrentURIMetaData")
1878        if metadata:
1879            metadata = XML.fromstring(really_utf8(metadata))
1880            md_title = metadata.findtext(".//{http://purl.org/dc/elements/1.1/}title")
1881
1882            if md_title:
1883                media["channel"] = md_title
1884
1885        return media
1886
1887    def get_speaker_info(self, refresh=False, timeout=None):
1888        """Get information about the Sonos speaker.
1889
1890        Arguments:
1891            refresh(bool): Refresh the speaker info cache.
1892            timeout: How long to wait for the server to send
1893                data before giving up, as a float, or a
1894                ``(connect timeout, read timeout)`` tuple
1895                e.g. (3, 5). Default is no timeout.
1896
1897        Returns:
1898            dict: Information about the Sonos speaker, such as the UID,
1899            MAC Address, and Zone Name.
1900        """
1901        if self.speaker_info and refresh is False:
1902            return self.speaker_info
1903        else:
1904            response = requests.get(
1905                "http://" + self.ip_address + ":1400/xml/device_description.xml",
1906                timeout=timeout,
1907            )
1908            dom = XML.fromstring(response.content)
1909
1910        device = dom.find("{urn:schemas-upnp-org:device-1-0}device")
1911        if device is not None:
1912            self.speaker_info["zone_name"] = device.findtext(
1913                "{urn:schemas-upnp-org:device-1-0}roomName"
1914            )
1915
1916            # no zone icon in device_description.xml -> player icon
1917            self.speaker_info["player_icon"] = device.findtext(
1918                "{urn:schemas-upnp-org:device-1-0}iconList/"
1919                "{urn:schemas-upnp-org:device-1-0}icon/"
1920                "{urn:schemas-upnp-org:device-1-0}url"
1921            )
1922
1923            self.speaker_info["uid"] = self.uid
1924            self.speaker_info["serial_number"] = device.findtext(
1925                "{urn:schemas-upnp-org:device-1-0}serialNum"
1926            )
1927            self.speaker_info["software_version"] = device.findtext(
1928                "{urn:schemas-upnp-org:device-1-0}softwareVersion"
1929            )
1930            self.speaker_info["hardware_version"] = device.findtext(
1931                "{urn:schemas-upnp-org:device-1-0}hardwareVersion"
1932            )
1933            self.speaker_info["model_number"] = device.findtext(
1934                "{urn:schemas-upnp-org:device-1-0}modelNumber"
1935            )
1936            self.speaker_info["model_name"] = device.findtext(
1937                "{urn:schemas-upnp-org:device-1-0}modelName"
1938            )
1939            self.speaker_info["display_version"] = device.findtext(
1940                "{urn:schemas-upnp-org:device-1-0}displayVersion"
1941            )
1942
1943            # no mac address - extract from serial number
1944            mac = self.speaker_info["serial_number"].split(":")[0]
1945            self.speaker_info["mac_address"] = mac
1946
1947            return self.speaker_info
1948        return None
1949
1950    def get_current_transport_info(self):
1951        """Get the current playback state.
1952
1953        Returns:
1954            dict: The following information about the
1955            speaker's playing state:
1956
1957            *   current_transport_state (``PLAYING``, ``TRANSITIONING``,
1958                ``PAUSED_PLAYBACK``, ``STOPPED``)
1959            *   current_transport_status (OK, ?)
1960            *   current_speed(1, ?)
1961
1962        This allows us to know if speaker is playing or not. Don't know other
1963        states of CurrentTransportStatus and CurrentSpeed.
1964        """
1965        response = self.avTransport.GetTransportInfo(
1966            [
1967                ("InstanceID", 0),
1968            ]
1969        )
1970
1971        playstate = {
1972            "current_transport_status": "",
1973            "current_transport_state": "",
1974            "current_transport_speed": "",
1975        }
1976
1977        playstate["current_transport_state"] = response["CurrentTransportState"]
1978        playstate["current_transport_status"] = response["CurrentTransportStatus"]
1979        playstate["current_transport_speed"] = response["CurrentSpeed"]
1980
1981        return playstate
1982
1983    @property
1984    @only_on_master
1985    def available_actions(self):
1986        """The transport actions that are currently available on the
1987        speaker.
1988
1989        :returns: list: A list of strings representing the available actions, such as
1990                    ['Set', 'Stop', 'Play'].
1991
1992        Possible list items are: 'Set', 'Stop', 'Pause', 'Play',
1993        'Next', 'Previous', 'SeekTime', 'SeekTrackNr'.
1994        """
1995        result = self.avTransport.GetCurrentTransportActions([("InstanceID", 0)])
1996        actions = result["Actions"]
1997        # The actions might look like 'X_DLNA_SeekTime', but we only want the
1998        # last part
1999        return [action.split("_")[-1] for action in actions.split(", ")]
2000
2001    def get_queue(self, start=0, max_items=100, full_album_art_uri=False):
2002        """Get information about the queue.
2003
2004        :param start: Starting number of returned matches
2005        :param max_items: Maximum number of returned matches
2006        :param full_album_art_uri: If the album art URI should include the
2007            IP address
2008        :returns: A :py:class:`~.soco.data_structures.Queue` object
2009
2010        This method is heavily based on Sam Soffes (aka soffes) ruby
2011        implementation
2012        """
2013        queue = []
2014        response = self.contentDirectory.Browse(
2015            [
2016                ("ObjectID", "Q:0"),
2017                ("BrowseFlag", "BrowseDirectChildren"),
2018                ("Filter", "*"),
2019                ("StartingIndex", start),
2020                ("RequestedCount", max_items),
2021                ("SortCriteria", ""),
2022            ]
2023        )
2024        result = response["Result"]
2025
2026        metadata = {}
2027        for tag in ["NumberReturned", "TotalMatches", "UpdateID"]:
2028            metadata[camel_to_underscore(tag)] = int(response[tag])
2029
2030        # I'm not sure this necessary (any more). Even with an empty queue,
2031        # there is still a result object. This shoud be investigated.
2032        if not result:
2033            # pylint: disable=star-args
2034            return Queue(queue, **metadata)
2035
2036        items = from_didl_string(result)
2037        for item in items:
2038            # Check if the album art URI should be fully qualified
2039            if full_album_art_uri:
2040                self.music_library._update_album_art_to_full_uri(item)
2041            queue.append(item)
2042
2043        # pylint: disable=star-args
2044        return Queue(queue, **metadata)
2045
2046    @property
2047    def queue_size(self):
2048        """int: Size of the queue."""
2049        response = self.contentDirectory.Browse(
2050            [
2051                ("ObjectID", "Q:0"),
2052                ("BrowseFlag", "BrowseMetadata"),
2053                ("Filter", "*"),
2054                ("StartingIndex", 0),
2055                ("RequestedCount", 1),
2056                ("SortCriteria", ""),
2057            ]
2058        )
2059        dom = XML.fromstring(really_utf8(response["Result"]))
2060
2061        queue_size = None
2062        container = dom.find("{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}container")
2063        if container is not None:
2064            child_count = container.get("childCount")
2065            if child_count is not None:
2066                queue_size = int(child_count)
2067
2068        return queue_size
2069
2070    def get_sonos_playlists(self, *args, **kwargs):
2071        """Convenience method for calling
2072        ``soco.music_library.get_music_library_information('sonos_playlists')``
2073
2074        Refer to the docstring for that method: `get_music_library_information`
2075
2076        """
2077        args = tuple(["sonos_playlists"] + list(args))
2078        return self.music_library.get_music_library_information(*args, **kwargs)
2079
2080    @only_on_master
2081    def add_uri_to_queue(self, uri, position=0, as_next=False):
2082        """Add the URI to the queue.
2083
2084        For arguments and return value see `add_to_queue`.
2085        """
2086        # FIXME: The res.protocol_info should probably represent the mime type
2087        # etc of the uri. But this seems OK.
2088        res = [DidlResource(uri=uri, protocol_info="x-rincon-playlist:*:*:*")]
2089        item = DidlObject(resources=res, title="", parent_id="", item_id="")
2090        return self.add_to_queue(item, position, as_next)
2091
2092    @only_on_master
2093    def add_to_queue(self, queueable_item, position=0, as_next=False):
2094        """Add a queueable item to the queue.
2095
2096        Args:
2097            queueable_item (DidlObject or MusicServiceItem): The item to be
2098                added to the queue
2099            position (int): The index (1-based) at which the URI should be
2100                added. Default is 0 (add URI at the end of the queue).
2101            as_next (bool): Whether this URI should be played as the next
2102                track in shuffle mode. This only works if ``play_mode=SHUFFLE``.
2103
2104        Returns:
2105            int: The index of the new item in the queue.
2106        """
2107        metadata = to_didl_string(queueable_item)
2108        response = self.avTransport.AddURIToQueue(
2109            [
2110                ("InstanceID", 0),
2111                ("EnqueuedURI", queueable_item.resources[0].uri),
2112                ("EnqueuedURIMetaData", metadata),
2113                ("DesiredFirstTrackNumberEnqueued", position),
2114                ("EnqueueAsNext", int(as_next)),
2115            ]
2116        )
2117        qnumber = response["FirstTrackNumberEnqueued"]
2118        return int(qnumber)
2119
2120    def add_multiple_to_queue(self, items, container=None):
2121        """Add a sequence of items to the queue.
2122
2123        Args:
2124            items (list): A sequence of items to the be added to the queue
2125            container (DidlObject, optional): A container object which
2126                includes the items.
2127        """
2128        if container is not None:
2129            container_uri = container.resources[0].uri
2130            container_metadata = to_didl_string(container)
2131        else:
2132            container_uri = ""  # Sonos seems to accept this as well
2133            container_metadata = ""  # pylint: disable=redefined-variable-type
2134
2135        chunk_size = 16  # With each request, we can only add 16 items
2136        item_list = list(items)  # List for slicing
2137        for index in range(0, len(item_list), chunk_size):
2138            chunk = item_list[index : index + chunk_size]
2139            uris = " ".join([item.resources[0].uri for item in chunk])
2140            uri_metadata = " ".join([to_didl_string(item) for item in chunk])
2141            self.avTransport.AddMultipleURIsToQueue(
2142                [
2143                    ("InstanceID", 0),
2144                    ("UpdateID", 0),
2145                    ("NumberOfURIs", len(chunk)),
2146                    ("EnqueuedURIs", uris),
2147                    ("EnqueuedURIsMetaData", uri_metadata),
2148                    ("ContainerURI", container_uri),
2149                    ("ContainerMetaData", container_metadata),
2150                    ("DesiredFirstTrackNumberEnqueued", 0),
2151                    ("EnqueueAsNext", 0),
2152                ]
2153            )
2154
2155    @only_on_master
2156    def remove_from_queue(self, index):
2157        """Remove a track from the queue by index. The index number is
2158        required as an argument, where the first index is 0.
2159
2160        Args:
2161            index (int): The (0-based) index of the track to remove
2162        """
2163        # TODO: what do these parameters actually do?
2164        updid = "0"
2165        objid = "Q:0/" + str(index + 1)
2166        self.avTransport.RemoveTrackFromQueue(
2167            [
2168                ("InstanceID", 0),
2169                ("ObjectID", objid),
2170                ("UpdateID", updid),
2171            ]
2172        )
2173
2174    @only_on_master
2175    def clear_queue(self):
2176        """Remove all tracks from the queue."""
2177        self.avTransport.RemoveAllTracksFromQueue(
2178            [
2179                ("InstanceID", 0),
2180            ]
2181        )
2182
2183    @deprecated("0.13", "soco.music_library.get_favorite_radio_shows", "0.15", True)
2184    def get_favorite_radio_shows(self, start=0, max_items=100):
2185        """Get favorite radio shows from Sonos' Radio app.
2186
2187        Returns:
2188            dict: A dictionary containing the total number of favorites, the
2189            number of favorites returned, and the actual list of favorite radio
2190            shows, represented as a dictionary with ``'title'`` and ``'uri'``
2191            keys.
2192
2193        Depending on what you're building, you'll want to check to see if the
2194        total number of favorites is greater than the amount you
2195        requested (``max_items``), if it is, use ``start`` to page through and
2196        get the entire list of favorites.
2197        """
2198        message = (
2199            "The output type of this method will probably change in "
2200            "the future to use SoCo data structures"
2201        )
2202        warnings.warn(message, stacklevel=2)
2203        return self.__get_favorites(RADIO_SHOWS, start, max_items)
2204
2205    @deprecated("0.13", "soco.music_library.get_favorite_radio_stations", "0.15", True)
2206    def get_favorite_radio_stations(self, start=0, max_items=100):
2207        """Get favorite radio stations from Sonos' Radio app.
2208
2209        See :meth:`get_favorite_radio_shows` for return type and remarks.
2210        """
2211        message = (
2212            "The output type of this method will probably change in "
2213            "the future to use SoCo data structures"
2214        )
2215        warnings.warn(message, stacklevel=2)
2216        return self.__get_favorites(RADIO_STATIONS, start, max_items)
2217
2218    @deprecated("0.13", "soco.music_library.get_sonos_favorites", "0.15", True)
2219    def get_sonos_favorites(self, start=0, max_items=100):
2220        """Get Sonos favorites.
2221
2222        See :meth:`get_favorite_radio_shows` for return type and remarks.
2223        """
2224        message = (
2225            "The output type of this method will probably change in "
2226            "the future to use SoCo data structures"
2227        )
2228        warnings.warn(message, stacklevel=2)
2229        return self.__get_favorites(SONOS_FAVORITES, start, max_items)
2230
2231    def __get_favorites(self, favorite_type, start=0, max_items=100):
2232        """Helper method for `get_favorite_radio_*` methods.
2233
2234        Args:
2235            favorite_type (str): Specify either `RADIO_STATIONS` or
2236                `RADIO_SHOWS`.
2237            start (int): Which number to start the retrieval from. Used for
2238                paging.
2239            max_items (int): The total number of results to return.
2240
2241        """
2242        if favorite_type not in (RADIO_SHOWS, RADIO_STATIONS):
2243            favorite_type = SONOS_FAVORITES
2244
2245        response = self.contentDirectory.Browse(
2246            [
2247                (
2248                    "ObjectID",
2249                    "FV:2"
2250                    if favorite_type is SONOS_FAVORITES
2251                    else "R:0/{}".format(favorite_type),
2252                ),
2253                ("BrowseFlag", "BrowseDirectChildren"),
2254                ("Filter", "*"),
2255                ("StartingIndex", start),
2256                ("RequestedCount", max_items),
2257                ("SortCriteria", ""),
2258            ]
2259        )
2260        result = {}
2261        favorites = []
2262        results_xml = response["Result"]
2263
2264        if results_xml != "":
2265            # Favorites are returned in DIDL-Lite format
2266            metadata = XML.fromstring(really_utf8(results_xml))
2267
2268            for item in metadata.findall(
2269                "{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}container"
2270                if favorite_type == RADIO_SHOWS
2271                else "{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}item"
2272            ):
2273                favorite = {}
2274                favorite["title"] = item.findtext(
2275                    "{http://purl.org/dc/elements/1.1/}title"
2276                )
2277                favorite["uri"] = item.findtext(
2278                    "{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}res"
2279                )
2280                if favorite_type == SONOS_FAVORITES:
2281                    favorite["meta"] = item.findtext(
2282                        "{urn:schemas-rinconnetworks-com:metadata-1-0/}resMD"
2283                    )
2284                favorites.append(favorite)
2285
2286        result["total"] = response["TotalMatches"]
2287        result["returned"] = len(favorites)
2288        result["favorites"] = favorites
2289
2290        return result
2291
2292    def create_sonos_playlist(self, title):
2293        """Create a new empty Sonos playlist.
2294
2295        Args:
2296            title: Name of the playlist
2297
2298        :rtype: :py:class:`~.soco.data_structures.DidlPlaylistContainer`
2299        """
2300        response = self.avTransport.CreateSavedQueue(
2301            [
2302                ("InstanceID", 0),
2303                ("Title", title),
2304                ("EnqueuedURI", ""),
2305                ("EnqueuedURIMetaData", ""),
2306            ]
2307        )
2308
2309        item_id = response["AssignedObjectID"]
2310        obj_id = item_id.split(":", 2)[1]
2311        uri = "file:///jffs/settings/savedqueues.rsq#{}".format(obj_id)
2312
2313        res = [DidlResource(uri=uri, protocol_info="x-rincon-playlist:*:*:*")]
2314        return DidlPlaylistContainer(
2315            resources=res, title=title, parent_id="SQ:", item_id=item_id
2316        )
2317
2318    @only_on_master
2319    # pylint: disable=invalid-name
2320    def create_sonos_playlist_from_queue(self, title):
2321        """Create a new Sonos playlist from the current queue.
2322
2323        Args:
2324            title: Name of the playlist
2325
2326        :rtype: :py:class:`~.soco.data_structures.DidlPlaylistContainer`
2327        """
2328        # Note: probably same as Queue service method SaveAsSonosPlaylist
2329        # but this has not been tested.  This method is what the
2330        # controller uses.
2331        response = self.avTransport.SaveQueue(
2332            [("InstanceID", 0), ("Title", title), ("ObjectID", "")]
2333        )
2334        item_id = response["AssignedObjectID"]
2335        obj_id = item_id.split(":", 2)[1]
2336        uri = "file:///jffs/settings/savedqueues.rsq#{}".format(obj_id)
2337        res = [DidlResource(uri=uri, protocol_info="x-rincon-playlist:*:*:*")]
2338        return DidlPlaylistContainer(
2339            resources=res, title=title, parent_id="SQ:", item_id=item_id
2340        )
2341
2342    @only_on_master
2343    def remove_sonos_playlist(self, sonos_playlist):
2344        """Remove a Sonos playlist.
2345
2346        Args:
2347            sonos_playlist (DidlPlaylistContainer): Sonos playlist to remove
2348                or the item_id (str).
2349
2350        Returns:
2351            bool: True if succesful, False otherwise
2352
2353        Raises:
2354            SoCoUPnPException: If sonos_playlist does not point to a valid
2355                object.
2356
2357        """
2358        object_id = getattr(sonos_playlist, "item_id", sonos_playlist)
2359        return self.contentDirectory.DestroyObject([("ObjectID", object_id)])
2360
2361    def add_item_to_sonos_playlist(self, queueable_item, sonos_playlist):
2362        """Adds a queueable item to a Sonos' playlist.
2363
2364        Args:
2365            queueable_item (DidlObject): the item to add to the Sonos' playlist
2366            sonos_playlist (DidlPlaylistContainer): the Sonos' playlist to
2367                which the item should be added
2368        """
2369        # Get the update_id for the playlist
2370        response, _ = self.music_library._music_lib_search(sonos_playlist.item_id, 0, 1)
2371        update_id = response["UpdateID"]
2372
2373        # Form the metadata for queueable_item
2374        metadata = to_didl_string(queueable_item)
2375
2376        # Make the request
2377        self.avTransport.AddURIToSavedQueue(
2378            [
2379                ("InstanceID", 0),
2380                ("UpdateID", update_id),
2381                ("ObjectID", sonos_playlist.item_id),
2382                ("EnqueuedURI", queueable_item.resources[0].uri),
2383                ("EnqueuedURIMetaData", metadata),
2384                # 2 ** 32 - 1 = 4294967295, this field has always this value. Most
2385                # likely, playlist positions are represented as a 32 bit uint and
2386                # this is therefore the largest index possible. Asking to add at
2387                # this index therefore probably amounts to adding it "at the end"
2388                ("AddAtIndex", 4294967295),
2389            ]
2390        )
2391
2392    @only_on_master
2393    def set_sleep_timer(self, sleep_time_seconds):
2394        """Sets the sleep timer.
2395
2396        Args:
2397            sleep_time_seconds (int or NoneType): How long to wait before
2398                turning off speaker in seconds, None to cancel a sleep timer.
2399                Maximum value of 86399
2400
2401        Raises:
2402            SoCoException: Upon errors interacting with Sonos controller
2403            ValueError: Argument/Syntax errors
2404
2405        """
2406        # Note: A value of None for sleep_time_seconds is valid, and needs to
2407        # be preserved distinctly separate from 0. 0 means go to sleep now,
2408        # which will immediately start the sound tappering, and could be a
2409        # useful feature, while None means cancel the current timer
2410        try:
2411            if sleep_time_seconds is None:
2412                sleep_time = ""
2413            else:
2414                sleep_time = format(datetime.timedelta(seconds=int(sleep_time_seconds)))
2415            self.avTransport.ConfigureSleepTimer(
2416                [
2417                    ("InstanceID", 0),
2418                    ("NewSleepTimerDuration", sleep_time),
2419                ]
2420            )
2421        except SoCoUPnPException as err:
2422            if "Error 402 received" in str(err):
2423                raise ValueError(
2424                    "invalid sleep_time_seconds, must be integer \
2425                    value between 0 and 86399 inclusive or None"
2426                ) from err
2427            raise
2428        except ValueError as error:
2429            raise ValueError(
2430                "invalid sleep_time_seconds, must be integer \
2431                value between 0 and 86399 inclusive or None"
2432            ) from error
2433
2434    @only_on_master
2435    def get_sleep_timer(self):
2436        """Retrieves remaining sleep time, if any
2437
2438        Returns:
2439            int or NoneType: Number of seconds left in timer. If there is no
2440                sleep timer currently set it will return None.
2441        """
2442        resp = self.avTransport.GetRemainingSleepTimerDuration(
2443            [
2444                ("InstanceID", 0),
2445            ]
2446        )
2447        if resp["RemainingSleepTimerDuration"]:
2448            times = resp["RemainingSleepTimerDuration"].split(":")
2449            return int(times[0]) * 3600 + int(times[1]) * 60 + int(times[2])
2450        else:
2451            return None
2452
2453    @only_on_master
2454    def reorder_sonos_playlist(self, sonos_playlist, tracks, new_pos, update_id=0):
2455        """Reorder and/or Remove tracks in a Sonos playlist.
2456
2457        The underlying call is quite complex as it can both move a track
2458        within the list or delete a track from the playlist.  All of this
2459        depends on what tracks and new_pos specify.
2460
2461        If a list is specified for tracks, then a list must be used for
2462        new_pos. Each list element is a discrete modification and the next
2463        list operation must anticipate the new state of the playlist.
2464
2465        If a comma formatted string to tracks is specified, then use
2466        a similiar string to specify new_pos. Those operations should be
2467        ordered from the end of the list to the beginning
2468
2469        See the helper methods
2470        :py:meth:`clear_sonos_playlist`, :py:meth:`move_in_sonos_playlist`,
2471        :py:meth:`remove_from_sonos_playlist` for simplified usage.
2472
2473        update_id - If you have a series of operations, tracking the update_id
2474        and setting it, will save a lookup operation.
2475
2476        Examples:
2477          To reorder the first two tracks::
2478
2479            # sonos_playlist specified by the DidlPlaylistContainer object
2480            sonos_playlist = device.get_sonos_playlists()[0]
2481            device.reorder_sonos_playlist(sonos_playlist,
2482                                          tracks=[0, ], new_pos=[1, ])
2483            # OR specified by the item_id
2484            device.reorder_sonos_playlist('SQ:0', tracks=[0, ], new_pos=[1, ])
2485
2486          To delete the second track::
2487
2488            # tracks/new_pos are a list of int
2489            device.reorder_sonos_playlist(sonos_playlist,
2490                                          tracks=[1, ], new_pos=[None, ])
2491            # OR tracks/new_pos are a list of int-like
2492            device.reorder_sonos_playlist(sonos_playlist,
2493                                          tracks=['1', ], new_pos=['', ])
2494            # OR tracks/new_pos are strings - no transform is done
2495            device.reorder_sonos_playlist(sonos_playlist, tracks='1',
2496                                          new_pos='')
2497
2498          To reverse the order of a playlist with 4 items::
2499
2500            device.reorder_sonos_playlist(sonos_playlist, tracks='3,2,1,0',
2501                                          new_pos='0,1,2,3')
2502
2503        Args:
2504            sonos_playlist
2505                (:py:class:`~.soco.data_structures.DidlPlaylistContainer`): The
2506                Sonos playlist object or the item_id (str) of the Sonos
2507                playlist.
2508            tracks: (list): list of track indices(int) to reorder. May also be
2509                a list of int like things. i.e. ``['0', '1',]`` OR it may be a
2510                str of comma separated int like things. ``"0,1"``.  Tracks are
2511                **0**-based. Meaning the first track is track 0, just like
2512                indexing into a Python list.
2513            new_pos (list): list of new positions (int|None)
2514                corresponding to track_list. MUST be the same type as
2515                ``tracks``. **0**-based, see tracks above. ``None`` is the
2516                indicator to remove the track. If using a list of strings,
2517                then a remove is indicated by an empty string.
2518            update_id (int): operation id (default: 0) If set to 0, a lookup
2519                is done to find the correct value.
2520
2521        Returns:
2522            dict: Which contains 3 elements: change, length and update_id.
2523            Change in size between original playlist and the resulting
2524            playlist, the length of resulting playlist, and the new
2525            update_id.
2526
2527        Raises:
2528            SoCoUPnPException: If playlist does not exist or if your tracks
2529                and/or new_pos arguments are invalid.
2530        """
2531        # allow either a string 'SQ:10' or an object with item_id attribute.
2532        object_id = getattr(sonos_playlist, "item_id", sonos_playlist)
2533
2534        if isinstance(tracks, str):
2535            track_list = [
2536                tracks,
2537            ]
2538            position_list = [
2539                new_pos,
2540            ]
2541        elif isinstance(tracks, int):
2542            track_list = [
2543                tracks,
2544            ]
2545            if new_pos is None:
2546                new_pos = ""
2547            position_list = [
2548                new_pos,
2549            ]
2550        else:
2551            track_list = [str(x) for x in tracks]
2552            position_list = [str(x) if x is not None else "" for x in new_pos]
2553        # track_list = ','.join(track_list)
2554        # position_list = ','.join(position_list)
2555        if update_id == 0:  # retrieve the update id for the object
2556            response, _ = self.music_library._music_lib_search(object_id, 0, 1)
2557            update_id = response["UpdateID"]
2558        change = 0
2559
2560        for track, position in zip(track_list, position_list):
2561            if track == position:  # there is no move, a no-op
2562                continue
2563            response = self.avTransport.ReorderTracksInSavedQueue(
2564                [
2565                    ("InstanceID", 0),
2566                    ("ObjectID", object_id),
2567                    ("UpdateID", update_id),
2568                    ("TrackList", track),
2569                    ("NewPositionList", position),
2570                ]
2571            )
2572            change += int(response["QueueLengthChange"])
2573            update_id = int(response["NewUpdateID"])
2574        length = int(response["NewQueueLength"])
2575        response = {"change": change, "update_id": update_id, "length": length}
2576        return response
2577
2578    @only_on_master
2579    def clear_sonos_playlist(self, sonos_playlist, update_id=0):
2580        """Clear all tracks from a Sonos playlist.
2581        This is a convenience method for :py:meth:`reorder_sonos_playlist`.
2582
2583        Example::
2584
2585            device.clear_sonos_playlist(sonos_playlist)
2586
2587        Args:
2588            sonos_playlist
2589                (:py:class:`~.soco.data_structures.DidlPlaylistContainer`):
2590                Sonos playlist object or the item_id (str) of the Sonos
2591                playlist.
2592            update_id (int): Optional update counter for the object. If left
2593                at the default of 0, it will be looked up.
2594
2595        Returns:
2596            dict: See :py:meth:`reorder_sonos_playlist`
2597
2598        Raises:
2599            ValueError: If sonos_playlist specified by string and is not found.
2600            SoCoUPnPException: See :py:meth:`reorder_sonos_playlist`
2601        """
2602        if not isinstance(sonos_playlist, DidlPlaylistContainer):
2603            sonos_playlist = self.get_sonos_playlist_by_attr("item_id", sonos_playlist)
2604        count = self.music_library.browse(ml_item=sonos_playlist).total_matches
2605        tracks = ",".join([str(x) for x in range(count)])
2606        if tracks:
2607            return self.reorder_sonos_playlist(
2608                sonos_playlist, tracks=tracks, new_pos="", update_id=update_id
2609            )
2610        else:
2611            return {"change": 0, "update_id": update_id, "length": count}
2612
2613    @only_on_master
2614    def move_in_sonos_playlist(self, sonos_playlist, track, new_pos, update_id=0):
2615        """Move a track to a new position within a Sonos Playlist.
2616        This is a convenience method for :py:meth:`reorder_sonos_playlist`.
2617
2618        Example::
2619
2620            device.move_in_sonos_playlist(sonos_playlist, track=0, new_pos=1)
2621
2622        Args:
2623            sonos_playlist
2624                (:py:class:`~.soco.data_structures.DidlPlaylistContainer`):
2625                Sonos playlist object or the item_id (str) of the Sonos
2626                playlist.
2627            track (int): **0**-based position of the track to move. The first
2628                track is track 0, just like indexing into a Python list.
2629            new_pos (int): **0**-based location to move the track.
2630            update_id (int): Optional update counter for the object. If left
2631                at the default of 0, it will be looked up.
2632
2633        Returns:
2634            dict: See :py:meth:`reorder_sonos_playlist`
2635
2636        Raises:
2637            SoCoUPnPException: See :py:meth:`reorder_sonos_playlist`
2638        """
2639        return self.reorder_sonos_playlist(
2640            sonos_playlist, int(track), int(new_pos), update_id
2641        )
2642
2643    @only_on_master
2644    def remove_from_sonos_playlist(self, sonos_playlist, track, update_id=0):
2645        """Remove a track from a Sonos Playlist.
2646        This is a convenience method for :py:meth:`reorder_sonos_playlist`.
2647
2648        Example::
2649
2650            device.remove_from_sonos_playlist(sonos_playlist, track=0)
2651
2652        Args:
2653            sonos_playlist
2654                (:py:class:`~.soco.data_structures.DidlPlaylistContainer`):
2655                Sonos playlist object or the item_id (str) of the Sonos
2656                playlist.
2657            track (int): *0**-based position of the track to move. The first
2658                track is track 0, just like indexing into a Python list.
2659            update_id (int): Optional update counter for the object. If left
2660                at the default of 0, it will be looked up.
2661
2662        Returns:
2663            dict: See :py:meth:`reorder_sonos_playlist`
2664
2665        Raises:
2666            SoCoUPnPException: See :py:meth:`reorder_sonos_playlist`
2667        """
2668        return self.reorder_sonos_playlist(sonos_playlist, int(track), None, update_id)
2669
2670    @only_on_master
2671    def get_sonos_playlist_by_attr(self, attr_name, match):
2672        """Return the first Sonos Playlist DidlPlaylistContainer that
2673        matches the attribute specified.
2674
2675        Args:
2676            attr_name (str): DidlPlaylistContainer attribute to compare. The
2677                most useful being: 'title' and 'item_id'.
2678            match (str): Value to match.
2679
2680        Returns:
2681            (:class:`~.soco.data_structures.DidlPlaylistContainer`): The
2682                first matching playlist object.
2683
2684        Raises:
2685            (AttributeError): If indicated attribute name does not exist.
2686            (ValueError): If a match can not be found.
2687
2688        Example::
2689
2690            device.get_sonos_playlist_by_attr('title', 'Foo')
2691            device.get_sonos_playlist_by_attr('item_id', 'SQ:3')
2692
2693        """
2694        for sonos_playlist in self.get_sonos_playlists():
2695            if getattr(sonos_playlist, attr_name) == match:
2696                return sonos_playlist
2697        raise ValueError('No match on "{}" for value "{}"'.format(attr_name, match))
2698
2699    def get_battery_info(self, timeout=3.0):
2700        """Get battery information for a Sonos speaker.
2701
2702        Obtains battery information for Sonos speakers that report it. This only
2703        applies to Sonos Move speakers at the time of writing.
2704
2705        This method may only work on Sonos 'S2' systems.
2706
2707        Args:
2708            timeout (float, optional): The timeout to use when making the
2709                HTTP request.
2710
2711        Returns:
2712            dict: A `dict` containing battery status data.
2713
2714            Example return value::
2715
2716                {'Health': 'GREEN',
2717                 'Level': 100,
2718                 'Temperature': 'NORMAL',
2719                 'PowerSource': 'SONOS_CHARGING_RING'}
2720
2721        Raises:
2722            NotSupportedException: If the speaker does not report battery
2723                information.
2724            ConnectionError: If the HTTP connection failed, or returned an
2725                unsuccessful status code.
2726            TimeoutError: If making the HTTP connection, or reading the
2727                response, timed out.
2728        """
2729
2730        # Retrieve information from the speaker's status URL
2731        try:
2732            response = requests.get(
2733                "http://" + self.ip_address + ":1400/status/batterystatus",
2734                timeout=timeout,
2735            )
2736        except (ConnectTimeout, ReadTimeout) as error:
2737            raise TimeoutError from error
2738        except RequestsConnectionError as error:
2739            raise ConnectionError from error
2740
2741        if response.status_code != 200:
2742            raise ConnectionError
2743
2744        # Convert the XML response and traverse to obtain the battery information
2745        battery_info = {}
2746        try:
2747            zp_info = xmltodict.parse(response.text)["ZPSupportInfo"]
2748            for info_item in zp_info["LocalBatteryStatus"]["Data"]:
2749                battery_info[info_item["@name"]] = info_item["#text"]
2750            try:
2751                battery_info["Level"] = int(battery_info["Level"])
2752            except (KeyError, ValueError):
2753                pass
2754        except (KeyError, ExpatError, TypeError) as error:
2755            # Battery information not supported
2756            raise NotSupportedException from error
2757
2758        return battery_info
2759
2760
2761# definition section
2762
2763RADIO_STATIONS = 0
2764RADIO_SHOWS = 1
2765SONOS_FAVORITES = 2
2766
2767NS = {
2768    "dc": "{http://purl.org/dc/elements/1.1/}",
2769    "upnp": "{urn:schemas-upnp-org:metadata-1-0/upnp/}",
2770    "": "{urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/}",
2771}
2772
2773# Valid play modes and their meanings as (shuffle, repeat) tuples
2774PLAY_MODES = {
2775    "NORMAL": (False, False),
2776    "SHUFFLE_NOREPEAT": (True, False),
2777    "SHUFFLE": (True, True),
2778    "REPEAT_ALL": (False, True),
2779    "SHUFFLE_REPEAT_ONE": (True, "ONE"),
2780    "REPEAT_ONE": (False, "ONE"),
2781}
2782# Inverse mapping of PLAY_MODES
2783PLAY_MODE_BY_MEANING = {meaning: mode for mode, meaning in PLAY_MODES.items()}
2784
2785# Music source names
2786MUSIC_SRC_LIBRARY = "LIBRARY"
2787MUSIC_SRC_RADIO = "RADIO"
2788MUSIC_SRC_WEB_FILE = "WEB_FILE"
2789MUSIC_SRC_LINE_IN = "LINE_IN"
2790MUSIC_SRC_TV = "TV"
2791MUSIC_SRC_AIRPLAY = "AIRPLAY"
2792MUSIC_SRC_UNKNOWN = "UNKNOWN"
2793MUSIC_SRC_NONE = "NONE"
2794
2795# URI prefixes for music sources
2796SOURCES = {
2797    r"^$": MUSIC_SRC_NONE,
2798    r"^x-file-cifs:": MUSIC_SRC_LIBRARY,
2799    r"^x-rincon-mp3radio:": MUSIC_SRC_RADIO,
2800    r"^x-sonosapi-stream:": MUSIC_SRC_RADIO,
2801    r"^x-sonosapi-radio:": MUSIC_SRC_RADIO,
2802    r"^x-sonosapi-hls:": MUSIC_SRC_RADIO,
2803    r"^aac:": MUSIC_SRC_RADIO,
2804    r"^hls-radio:": MUSIC_SRC_RADIO,
2805    r"^https?:": MUSIC_SRC_WEB_FILE,
2806    r"^x-rincon-stream:": MUSIC_SRC_LINE_IN,
2807    r"^x-sonos-htastream:": MUSIC_SRC_TV,
2808    r"^x-sonos-vli:.*,airplay:": MUSIC_SRC_AIRPLAY,
2809}
2810
2811# Soundbar product names
2812SOUNDBARS = ("playbase", "playbar", "beam", "sonos amp", "arc", "arc sl")
2813
2814if config.SOCO_CLASS is None:
2815    config.SOCO_CLASS = SoCo
2816