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