Hello community,

here is the log from the commit of package python-PyChromecast for 
openSUSE:Factory checked in at 2019-06-18 15:00:21
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Comparing /work/SRC/openSUSE:Factory/python-PyChromecast (Old)
 and      /work/SRC/openSUSE:Factory/.python-PyChromecast.new.4811 (New)
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Package is "python-PyChromecast"

Tue Jun 18 15:00:21 2019 rev:5 rq:710551 version:3.2.2

Changes:
--------
--- /work/SRC/openSUSE:Factory/python-PyChromecast/python-PyChromecast.changes  
2019-03-06 15:50:09.128433168 +0100
+++ 
/work/SRC/openSUSE:Factory/.python-PyChromecast.new.4811/python-PyChromecast.changes
        2019-06-18 15:00:24.433268016 +0200
@@ -1,0 +2,12 @@
+Tue Jun 18 11:28:34 UTC 2019 - Marketa Calabkova <[email protected]>
+
+- Update to 3.2.2
+  * Improve matching of spotify device to handle audio groups
+  * Fix broken attempt to update status during tear down
+  * Add google home mini as audio device
+  * Add support for queue_next / queue_prev
+  * Take expiration from login and pass to controller
+  * Add multizone controller and multizone manager
+  * Remove the filters feature from get_chromecasts
+
+-------------------------------------------------------------------

Old:
----
  PyChromecast-2.5.2.tar.gz

New:
----
  PyChromecast-3.2.2.tar.gz

++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

Other differences:
------------------
++++++ python-PyChromecast.spec ++++++
--- /var/tmp/diff_new_pack.jeF2Fk/_old  2019-06-18 15:00:25.237267698 +0200
+++ /var/tmp/diff_new_pack.jeF2Fk/_new  2019-06-18 15:00:25.241267696 +0200
@@ -19,25 +19,19 @@
 %{?!python_module:%define python_module() python-%{**} python3-%{**}}
 %define skip_python2 1
 Name:           python-PyChromecast
-Version:        2.5.2
+Version:        3.2.2
 Release:        0
 Summary:        Python module to talk to Google Chromecast
 License:        MIT
 Group:          Development/Languages/Python
 URL:            https://github.com/balloob/pychromecast
 Source:         
https://files.pythonhosted.org/packages/source/P/PyChromecast/PyChromecast-%{version}.tar.gz
-BuildRequires:  %{python_module casttube >= 0.1.0}
-BuildRequires:  %{python_module protobuf >= 3.0.0}
-BuildRequires:  %{python_module requests >= 2.0}
 BuildRequires:  %{python_module setuptools}
-BuildRequires:  %{python_module six >= 1.10.0}
-BuildRequires:  %{python_module zeroconf >= 0.17.7}
 BuildRequires:  fdupes
 BuildRequires:  python-rpm-macros
 Requires:       python-casttube >= 0.1.0
 Requires:       python-protobuf >= 3.0.0
 Requires:       python-requests >= 2.0
-Requires:       python-six >= 1.10.0
 Requires:       python-zeroconf >= 0.17.7
 BuildArch:      noarch
 %python_subpackages
@@ -58,7 +52,7 @@
 
 %install
 %python_install
-%python_expand %fdupes -s %{buildroot}%{$python_sitelib}
+%python_expand %fdupes %{buildroot}%{$python_sitelib}
 
 %files %{python_files}
 %license LICENSE

++++++ PyChromecast-2.5.2.tar.gz -> PyChromecast-3.2.2.tar.gz ++++++
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/PKG-INFO 
new/PyChromecast-3.2.2/PKG-INFO
--- old/PyChromecast-2.5.2/PKG-INFO     2019-02-16 20:45:19.000000000 +0100
+++ new/PyChromecast-3.2.2/PKG-INFO     2019-05-13 19:51:06.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: PyChromecast
-Version: 2.5.2
+Version: 3.2.2
 Summary: Python module to talk to Google Chromecast.
 Home-page: https://github.com/balloob/pychromecast
 Author: Paulus Schoutsen
@@ -47,7 +47,7 @@
             ['Dev', 'Living Room', 'Den', 'Bedroom']
         
             >> cast = next(cc for cc in chromecasts if cc.device.friendly_name 
== "Living Room")
-            >> # Wait for cast device to be ready
+            >> # Start worker thread and wait for cast device to be ready
             >> cast.wait()
             >> print(cast.device)
             DeviceStatus(friendly_name='Living Room', model_name='Chromecast', 
manufacturer='Google Inc.', uuid=UUID('df6944da-f016-4cb8-97d0-3da2ccaa380b'), 
cast_type='cast')
@@ -142,13 +142,6 @@
             pychromecast.IGNORE_CEC.append('*')  # Ignore CEC on all devices
             pychromecast.IGNORE_CEC.append('Living Room')  # Ignore CEC on 
Chromecasts named Living Room
         
-        Maintainers
-        -----------
-        
-        -  Jan Borsodi (`@am0s`_)
-        -  Ryan Kraus (`@rmkraus`_)
-        -  Paulus Schoutsen (`@balloob`_, original author)
-        
         Thanks
         ------
         
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/PyChromecast.egg-info/PKG-INFO 
new/PyChromecast-3.2.2/PyChromecast.egg-info/PKG-INFO
--- old/PyChromecast-2.5.2/PyChromecast.egg-info/PKG-INFO       2019-02-16 
20:45:19.000000000 +0100
+++ new/PyChromecast-3.2.2/PyChromecast.egg-info/PKG-INFO       2019-05-13 
19:51:06.000000000 +0200
@@ -1,6 +1,6 @@
 Metadata-Version: 1.1
 Name: PyChromecast
-Version: 2.5.2
+Version: 3.2.2
 Summary: Python module to talk to Google Chromecast.
 Home-page: https://github.com/balloob/pychromecast
 Author: Paulus Schoutsen
@@ -47,7 +47,7 @@
             ['Dev', 'Living Room', 'Den', 'Bedroom']
         
             >> cast = next(cc for cc in chromecasts if cc.device.friendly_name 
== "Living Room")
-            >> # Wait for cast device to be ready
+            >> # Start worker thread and wait for cast device to be ready
             >> cast.wait()
             >> print(cast.device)
             DeviceStatus(friendly_name='Living Room', model_name='Chromecast', 
manufacturer='Google Inc.', uuid=UUID('df6944da-f016-4cb8-97d0-3da2ccaa380b'), 
cast_type='cast')
@@ -142,13 +142,6 @@
             pychromecast.IGNORE_CEC.append('*')  # Ignore CEC on all devices
             pychromecast.IGNORE_CEC.append('Living Room')  # Ignore CEC on 
Chromecasts named Living Room
         
-        Maintainers
-        -----------
-        
-        -  Jan Borsodi (`@am0s`_)
-        -  Ryan Kraus (`@rmkraus`_)
-        -  Paulus Schoutsen (`@balloob`_, original author)
-        
         Thanks
         ------
         
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/PyChromecast.egg-info/SOURCES.txt 
new/PyChromecast-3.2.2/PyChromecast.egg-info/SOURCES.txt
--- old/PyChromecast-2.5.2/PyChromecast.egg-info/SOURCES.txt    2019-02-16 
20:45:19.000000000 +0100
+++ new/PyChromecast-3.2.2/PyChromecast.egg-info/SOURCES.txt    2019-05-13 
19:51:06.000000000 +0200
@@ -22,6 +22,7 @@
 pychromecast/controllers/__init__.py
 pychromecast/controllers/dashcast.py
 pychromecast/controllers/media.py
+pychromecast/controllers/multizone.py
 pychromecast/controllers/plex.py
 pychromecast/controllers/spotify.py
 pychromecast/controllers/youtube.py
\ No newline at end of file
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/README.rst 
new/PyChromecast-3.2.2/README.rst
--- old/PyChromecast-2.5.2/README.rst   2018-07-10 10:42:56.000000000 +0200
+++ new/PyChromecast-3.2.2/README.rst   2019-04-01 20:36:19.000000000 +0200
@@ -39,7 +39,7 @@
     ['Dev', 'Living Room', 'Den', 'Bedroom']
 
     >> cast = next(cc for cc in chromecasts if cc.device.friendly_name == 
"Living Room")
-    >> # Wait for cast device to be ready
+    >> # Start worker thread and wait for cast device to be ready
     >> cast.wait()
     >> print(cast.device)
     DeviceStatus(friendly_name='Living Room', model_name='Chromecast', 
manufacturer='Google Inc.', uuid=UUID('df6944da-f016-4cb8-97d0-3da2ccaa380b'), 
cast_type='cast')
@@ -134,13 +134,6 @@
     pychromecast.IGNORE_CEC.append('*')  # Ignore CEC on all devices
     pychromecast.IGNORE_CEC.append('Living Room')  # Ignore CEC on Chromecasts 
named Living Room
 
-Maintainers
------------
-
--  Jan Borsodi (`@am0s`_)
--  Ryan Kraus (`@rmkraus`_)
--  Paulus Schoutsen (`@balloob`_, original author)
-
 Thanks
 ------
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/pychromecast/__init__.py 
new/PyChromecast-3.2.2/pychromecast/__init__.py
--- old/PyChromecast-2.5.2/pychromecast/__init__.py     2019-02-12 
20:29:47.000000000 +0100
+++ new/PyChromecast-3.2.2/pychromecast/__init__.py     2019-04-01 
20:36:19.000000000 +0200
@@ -75,8 +75,6 @@
                          and returns a function which can be executed to stop
                          discovery.
 
-    ex: get_chromecasts(friendly_name="Living Room")
-
     May return an empty list if no chromecasts were found.
 
     Tries is specified if you want to limit the number of times the
@@ -121,7 +119,7 @@
         return internal_stop
 
 
-# pylint: disable=too-many-instance-attributes
+# pylint: disable=too-many-instance-attributes, too-many-public-methods
 class Chromecast(object):
     """
     Class to interface with a ChromeCast.
@@ -208,9 +206,6 @@
         self.register_connection_listener = \
             self.socket_client.register_connection_listener
 
-        if blocking:
-            self.socket_client.start()
-
     @property
     def ignore_cec(self):
         """ Returns whether the CEC data should be ignored. """
@@ -322,6 +317,8 @@
         Waits until the cast device is ready for communication. The device
         is ready as soon a status message has been received.
 
+        If the worker thread is not already running, it will be started.
+
         If the status has already been received then the method returns
         immediately.
 
@@ -329,8 +326,17 @@
                         operation in seconds (or fractions thereof). Or None
                         to block forever.
         """
+        if not self.socket_client.isAlive():
+            self.socket_client.start()
         self.status_event.wait(timeout=timeout)
 
+    def connect(self):
+        """ Connect to the chromecast.
+
+            Must only be called if the worker thread will not be started.
+        """
+        self.socket_client.connect()
+
     def disconnect(self, timeout=None, blocking=True):
         """
         Disconnects the chromecast and waits for it to terminate.
@@ -356,6 +362,12 @@
         """
         self.socket_client.join(timeout=timeout)
 
+    def start(self):
+        """
+        Start the chromecast connection's worker thread.
+        """
+        self.socket_client.start()
+
     def __del__(self):
         try:
             self.socket_client.stop.set()
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/pychromecast/controllers/media.py 
new/PyChromecast-3.2.2/pychromecast/controllers/media.py
--- old/PyChromecast-2.5.2/pychromecast/controllers/media.py    2018-07-10 
10:42:56.000000000 +0200
+++ new/PyChromecast-3.2.2/pychromecast/controllers/media.py    2019-04-01 
20:36:19.000000000 +0200
@@ -3,6 +3,7 @@
 on the Chromecast.
 """
 from datetime import datetime
+import logging
 
 from collections import namedtuple
 import threading
@@ -22,14 +23,16 @@
 
 MESSAGE_TYPE = 'type'
 
+TYPE_EDIT_TRACKS_INFO = "EDIT_TRACKS_INFO"
 TYPE_GET_STATUS = "GET_STATUS"
+TYPE_LOAD = "LOAD"
 TYPE_MEDIA_STATUS = "MEDIA_STATUS"
-TYPE_PLAY = "PLAY"
 TYPE_PAUSE = "PAUSE"
-TYPE_STOP = "STOP"
-TYPE_LOAD = "LOAD"
+TYPE_PLAY = "PLAY"
+TYPE_QUEUE_NEXT = "QUEUE_NEXT"
+TYPE_QUEUE_PREV = "QUEUE_PREV"
 TYPE_SEEK = "SEEK"
-TYPE_EDIT_TRACKS_INFO = "EDIT_TRACKS_INFO"
+TYPE_STOP = "STOP"
 
 METADATA_TYPE_GENERIC = 0
 METADATA_TYPE_TVSHOW = 1
@@ -37,16 +40,37 @@
 METADATA_TYPE_MUSICTRACK = 3
 METADATA_TYPE_PHOTO = 4
 
+# From www.gstatic.com/cast/sdk/libs/caf_receiver/v3/cast_receiver_framework.js
 CMD_SUPPORT_PAUSE = 1
 CMD_SUPPORT_SEEK = 2
 CMD_SUPPORT_STREAM_VOLUME = 4
 CMD_SUPPORT_STREAM_MUTE = 8
+# ALL_BASIC_MEDIA = PAUSE | SEEK | VOLUME | MUTE | EDIT_TRACKS | PLAYBACK_RATE
+CMD_SUPPORT_ALL_BASIC_MEDIA = 12303
+CMD_SUPPORT_QUEUE_NEXT = 64
+CMD_SUPPORT_QUEUE_PREV = 128
+CMD_SUPPORT_QUEUE_SHUFFLE = 256
+CMD_SUPPORT_QUEUE_REPEAT_ALL = 1024
+CMD_SUPPORT_QUEUE_REPEAT_ONE = 2048
+CMD_SUPPORT_QUEUE_REPEAT = 3072
+CMD_SUPPORT_SKIP_AD = 512
+CMD_SUPPORT_EDIT_TRACKS = 4096
+CMD_SUPPORT_PLAYBACK_RATE = 8192
+CMD_SUPPORT_LIKE = 16384
+CMD_SUPPORT_DISLIKE = 32768
+CMD_SUPPORT_FOLLOW = 65536
+CMD_SUPPORT_UNFOLLOW = 131072
+CMD_SUPPORT_STREAM_TRANSFER = 262144
+
+# Legacy?
 CMD_SUPPORT_SKIP_FORWARD = 16
 CMD_SUPPORT_SKIP_BACKWARD = 32
 
 
 MediaImage = namedtuple('MediaImage', 'url height width')
 
+_LOGGER = logging.getLogger(__name__)
+
 
 class MediaStatus(object):
     """ Class to hold the media status. """
@@ -215,6 +239,16 @@
         """ True if SKIP_BACKWARD is supported. """
         return bool(self.supported_media_commands & CMD_SUPPORT_SKIP_BACKWARD)
 
+    @property
+    def supports_queue_next(self):
+        """ True if QUEUE_NEXT is supported. """
+        return bool(self.supported_media_commands & CMD_SUPPORT_QUEUE_NEXT)
+
+    @property
+    def supports_queue_prev(self):
+        """ True if QUEUE_PREV is supported. """
+        return bool(self.supported_media_commands & CMD_SUPPORT_QUEUE_PREV)
+
     def update(self, data):
         """ New data will only contain the changed attributes. """
         if not data.get('status', []):
@@ -304,7 +338,7 @@
         return False
 
     def register_status_listener(self, listener):
-        """ Register a listener for new media statusses. A new status will
+        """ Register a listener for new media statuses. A new status will
             call listener.new_media_status(status) """
         self._status_listeners.append(listener)
 
@@ -386,6 +420,14 @@
                             "currentTime": position,
                             "resumeState": "PLAYBACK_START"})
 
+    def queue_next(self):
+        """ Send the QUEUE_NEXT command. """
+        self._send_command({MESSAGE_TYPE: TYPE_QUEUE_NEXT})
+
+    def queue_prev(self):
+        """ Send the QUEUE_PREV command. """
+        self._send_command({MESSAGE_TYPE: TYPE_QUEUE_PREV})
+
     def enable_subtitle(self, track_id):
         """ Enable specific text track. """
         self._send_command({
@@ -434,7 +476,8 @@
             try:
                 listener.new_media_status(self.status)
             except Exception:  # pylint: disable=broad-except
-                pass
+                _LOGGER.exception("Exception thrown when calling media status "
+                                  "callback")
 
     # pylint: disable=too-many-arguments
     def play_media(self, url, content_type, title=None, thumb=None,
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/PyChromecast-2.5.2/pychromecast/controllers/multizone.py 
new/PyChromecast-3.2.2/pychromecast/controllers/multizone.py
--- old/PyChromecast-2.5.2/pychromecast/controllers/multizone.py        
1970-01-01 01:00:00.000000000 +0100
+++ new/PyChromecast-3.2.2/pychromecast/controllers/multizone.py        
2019-04-01 20:36:19.000000000 +0200
@@ -0,0 +1,253 @@
+"""
+Controller to monitor audio group members.
+"""
+import logging
+
+from . import BaseController
+from ..socket_client import (
+    CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED,
+    CONNECTION_STATUS_LOST)
+
+_LOGGER = logging.getLogger(__name__)
+
+MESSAGE_TYPE = "type"
+MULTIZONE_NAMESPACE = "urn:x-cast:com.google.cast.multizone"
+TYPE_CASTING_GROUPS = "CASTING_GROUPS"
+TYPE_DEVICE_ADDED = "DEVICE_ADDED"
+TYPE_DEVICE_UPDATED = "DEVICE_UPDATED"
+TYPE_DEVICE_REMOVED = "DEVICE_REMOVED"
+TYPE_GET_CASTING_GROUPS = "GET_CASTING_GROUPS"
+TYPE_GET_STATUS = "GET_STATUS"
+TYPE_MULTIZONE_STATUS = "MULTIZONE_STATUS"
+TYPE_SESSION_UPDATED = "PLAYBACK_SESSION_UPDATED"
+
+
+class Listener:
+    """ Callback handler. """
+    def __init__(self, group_cast, casts):
+        """Initialize the listener."""
+        self._casts = casts
+        group_cast.register_status_listener(self)
+        group_cast.media_controller.register_status_listener(self)
+        group_cast.register_connection_listener(self)
+        self._mz = MultizoneController(group_cast.uuid)
+        self._mz.register_listener(self)
+        self._group_uuid = str(group_cast.uuid)
+        group_cast.register_handler(self._mz)
+
+    def new_cast_status(self, cast_status):
+        """Handle reception of a new CastStatus."""
+        casts = self._casts
+        group_members = self._mz.members
+        for member_uuid in group_members:
+            if member_uuid not in casts:
+                continue
+            for listener in list(casts[member_uuid]['listeners']):
+                listener.multizone_new_cast_status(
+                    self._group_uuid, cast_status)
+
+    def new_media_status(self, media_status):
+        """Handle reception of a new MediaStatus."""
+        casts = self._casts
+        group_members = self._mz.members
+        for member_uuid in group_members:
+            if member_uuid not in casts:
+                continue
+            for listener in list(casts[member_uuid]['listeners']):
+                listener.multizone_new_media_status(
+                    self._group_uuid, media_status)
+
+    def new_connection_status(self, conn_status):
+        """Handle reception of a new ConnectionStatus."""
+        if conn_status.status == CONNECTION_STATUS_CONNECTED:
+            self._mz.update_members()
+        if (conn_status.status == CONNECTION_STATUS_DISCONNECTED or
+                conn_status.status == CONNECTION_STATUS_LOST):
+            self._mz.reset_members()
+
+    def multizone_member_added(self, member_uuid):
+        """Handle added audio group member."""
+        casts = self._casts
+        if member_uuid not in casts:
+            casts[member_uuid] = {'listeners': [],
+                                  'groups': set()}
+        casts[member_uuid]['groups'].add(self._group_uuid)
+        for listener in list(casts[member_uuid]['listeners']):
+            listener.added_to_multizone(self._group_uuid)
+
+    def multizone_member_removed(self, member_uuid):
+        """Handle removed audio group member."""
+        casts = self._casts
+        if member_uuid not in casts:
+            casts[member_uuid] = {'listeners': [], 'groups': set()}
+        casts[member_uuid]['groups'].discard(self._group_uuid)
+        for listener in list(casts[member_uuid]['listeners']):
+            listener.removed_from_multizone(self._group_uuid)
+
+    def multizone_status_received(self):
+        """Handle reception of audio group status."""
+        pass
+
+
+class MultizoneManager:
+    """ Manage audio groups. """
+    def __init__(self):
+        # Protect self._casts because it will be accessed from callbacks from
+        # the casts' socket_client thread
+        self._casts = {}
+        self._groups = {}
+
+    def add_multizone(self, group_cast):
+        """ Start managing a group """
+        self._groups[str(group_cast.uuid)] = {
+            'chromecast': group_cast,
+            'listener': Listener(group_cast, self._casts),
+            'members': set()}
+
+    def remove_multizone(self, group_uuid):
+        """ Stop managing a group """
+        group_uuid = str(group_uuid)
+        group = self._groups.pop(group_uuid, None)
+        # Inform all group members that they are no longer members
+        if group is not None:
+            group['listener']._mz.reset_members()  # noqa: E501 pylint: 
disable=protected-access
+        for member in self._casts.values():
+            member['groups'].discard(group_uuid)
+
+    def register_listener(self, member_uuid, listener):
+        """ Register a listener for audio group changes of cast uuid.
+            On update will call:
+            listener.added_to_multizone(group_uuid)
+                The cast has been added to group uuid
+            listener.removed_from_multizone(group_uuid)
+                The cast has been removed from group uuid
+            listener.multizone_new_media_status(group_uuid, media_status)
+                The group uuid, of which the cast is a member, has new status
+            listener.multizone_new_cast_status(group_uuid, cast_status)
+                The group uuid, of which the cast is a member, has new status
+        """
+        member_uuid = str(member_uuid)
+        if member_uuid not in self._casts:
+            self._casts[member_uuid] = {'listeners': [],
+                                        'groups': set()}
+        self._casts[member_uuid]['listeners'].append(listener)
+
+    def deregister_listener(self, member_uuid, listener):
+        """ Deregister listener for audio group changes of cast uuid."""
+        self._casts[str(member_uuid)]['listeners'].remove(listener)
+
+    def get_multizone_memberships(self, member_uuid):
+        """ Return a list of audio groups in which cast member_uuid is a member
+        """
+        return list(self._casts[str(member_uuid)]['groups'])
+
+    def get_multizone_mediacontroller(self, group_uuid):
+        """ Get mediacontroller of a group """
+        return self._groups[str(group_uuid)]['chromecast'].media_controller
+
+
+class MultizoneController(BaseController):
+    """ Controller to monitor audio group members. """
+
+    def __init__(self, uuid):
+        self._members = {}
+        self._status_listeners = []
+        self._uuid = str(uuid)
+        super(MultizoneController, self).__init__(MULTIZONE_NAMESPACE,
+                                                  target_platform=True)
+
+    def _add_member(self, uuid, name):
+        if uuid not in self._members:
+            self._members[uuid] = name
+            _LOGGER.debug("(%s) Added member %s(%s), members: %s",
+                          self._uuid, uuid, name, self._members)
+            for listener in list(self._status_listeners):
+                listener.multizone_member_added(uuid)
+
+    def _remove_member(self, uuid):
+        name = self._members.pop(uuid, '<Unknown>')
+        _LOGGER.debug("(%s) Removed member %s(%s), members: %s",
+                      self._uuid, uuid, name, self._members)
+        for listener in list(self._status_listeners):
+            listener.multizone_member_removed(uuid)
+
+    def register_listener(self, listener):
+        """ Register a listener for audio group changes. On update will call:
+            listener.multizone_member_added(uuid)
+            listener.multizone_member_removed(uuid)
+            listener.multizone_status_received()
+        """
+        self._status_listeners.append(listener)
+
+    @property
+    def members(self):
+        """ Return a list of audio group members. """
+        return list(self._members.keys())
+
+    def reset_members(self):
+        """ Reset audio group members. """
+        for uuid in list(self._members):
+            self._remove_member(uuid)
+
+    def update_members(self):
+        """ Update audio group members. """
+        self.send_message({MESSAGE_TYPE: TYPE_GET_STATUS})
+
+    def get_casting_groups(self):
+        """ Send GET_CASTING_GROUPS message. """
+        self.send_message({MESSAGE_TYPE: TYPE_GET_CASTING_GROUPS})
+
+    def receive_message(self, message, data):  # noqa: E501 pylint: 
disable=too-many-return-statements
+        """ Called when a multizone message is received. """
+        if data[MESSAGE_TYPE] == TYPE_DEVICE_ADDED:
+            uuid = data['device']['deviceId']
+            name = data['device']['name']
+            self._add_member(uuid, name)
+            return True
+
+        if data[MESSAGE_TYPE] == TYPE_DEVICE_REMOVED:
+            uuid = data['deviceId']
+            self._remove_member(uuid)
+            return True
+
+        if data[MESSAGE_TYPE] == TYPE_DEVICE_UPDATED:
+            uuid = data['device']['deviceId']
+            name = data['device']['name']
+            self._add_member(uuid, name)
+            return True
+
+        if data[MESSAGE_TYPE] == TYPE_MULTIZONE_STATUS:
+            members = data['status']['devices']
+            members = \
+                {member['deviceId']: member['name'] for member in members}
+            removed_members = \
+                list(set(self._members.keys())-set(members.keys()))
+            added_members = list(set(members.keys())-set(self._members.keys()))
+            _LOGGER.debug("(%s) Added members %s, Removed members: %s",
+                          self._uuid, added_members, removed_members)
+
+            for uuid in removed_members:
+                self._remove_member(uuid)
+            for uuid in added_members:
+                self._add_member(uuid, members[uuid])
+
+            for listener in list(self._status_listeners):
+                listener.multizone_status_received()
+
+            return True
+
+        if data[MESSAGE_TYPE] == TYPE_SESSION_UPDATED:
+            # A temporary group has been formed
+            return True
+
+        if data[MESSAGE_TYPE] == TYPE_CASTING_GROUPS:
+            # Answer to GET_CASTING_GROUPS
+            return True
+
+        return False
+
+    def tear_down(self):
+        """ Called when controller is destroyed. """
+        super(MultizoneController, self).tear_down()
+
+        self._status_listeners[:] = []
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' 
old/PyChromecast-2.5.2/pychromecast/controllers/spotify.py 
new/PyChromecast-3.2.2/pychromecast/controllers/spotify.py
--- old/PyChromecast-2.5.2/pychromecast/controllers/spotify.py  2018-07-10 
10:42:56.000000000 +0200
+++ new/PyChromecast-3.2.2/pychromecast/controllers/spotify.py  2019-05-13 
19:47:11.000000000 +0200
@@ -2,14 +2,18 @@
 Controller to interface with Spotify.
 """
 import logging
-import time
+import threading
 
 from . import BaseController
 from ..config import APP_SPOTIFY
+from ..error import LaunchError
 
 APP_NAMESPACE = "urn:x-cast:com.spotify.chromecast.secure.v1"
-TYPE_STATUS = "setCredentials"
-TYPE_RESPONSE_STATUS = 'setCredentialsResponse'
+TYPE_GET_INFO = "getInfo"
+TYPE_GET_INFO_RESPONSE = "getInfoResponse"
+TYPE_SET_CREDENTIALS = "setCredentials"
+TYPE_SET_CREDENTIALS_ERROR = 'setCredentialsError'
+TYPE_SET_CREDENTIALS_RESPONSE = 'setCredentialsResponse'
 
 
 # pylint: disable=too-many-instance-attributes
@@ -19,32 +23,58 @@
     # pylint: disable=useless-super-delegation
     # The pylint rule useless-super-delegation doesn't realize
     # we are setting default values here.
-    def __init__(self, access_token):
+    def __init__(self, access_token, expires):
         super(SpotifyController, self).__init__(APP_NAMESPACE, APP_SPOTIFY)
+        if access_token is None or expires is None:
+            raise ValueError("access_token and expires cannot be empty")
 
         self.logger = logging.getLogger(__name__)
         self.session_started = False
         self.access_token = access_token
+        self.expires = expires
         self.is_launched = False
+        self.device = None
+        self.credential_error = False
+        self.waiting = threading.Event()
     # pylint: enable=useless-super-delegation
 
     # pylint: disable=unused-argument,no-self-use
     def receive_message(self, message, data):
-        """ Currently not doing anything with received messages. """
-        if data['type'] == TYPE_RESPONSE_STATUS:
+        """ Handle the auth flow and active player selection """
+        if data['type'] == TYPE_SET_CREDENTIALS_RESPONSE:
+            self.send_message({'type': TYPE_GET_INFO, 'payload': {}})
+        if data['type'] == TYPE_SET_CREDENTIALS_ERROR:
+            self.device = None
+            self.credential_error = True
+            self.waiting.set()
+        if data['type'] == TYPE_GET_INFO_RESPONSE:
+            self.device = data['payload']['deviceID']
             self.is_launched = True
+            self.waiting.set()
         return True
 
-    def launch_app(self):
-        """ Launch main application """
+    def launch_app(self, timeout=10):
+        """
+        Launch Spotify application.
+
+        Will raise a LaunchError exception if there is no response from the
+        Spotify app within timeout seconds.
+        """
 
         def callback():
             """Callback function"""
-            self.send_message({"type": TYPE_STATUS,
-                               "credentials": self.access_token})
-
+            self.send_message({"type": TYPE_SET_CREDENTIALS,
+                               "credentials": self.access_token,
+                               "expiresIn": self.expires})
+
+        self.device = None
+        self.credential_error = False
+        self.waiting.clear()
         self.launch(callback_function=callback)
 
         # Need to wait for Spotify to be launched on Chromecast completely
-        while not self.is_launched:
-            time.sleep(1)
+        self.waiting.wait(timeout)
+
+        if not self.is_launched:
+            raise LaunchError(
+                "Timeout when waiting for status response from Spotify app")
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/pychromecast/dial.py 
new/PyChromecast-3.2.2/pychromecast/dial.py
--- old/PyChromecast-2.5.2/pychromecast/dial.py 2019-02-15 18:43:54.000000000 
+0100
+++ new/PyChromecast-3.2.2/pychromecast/dial.py 2019-04-27 12:39:22.000000000 
+0200
@@ -28,6 +28,7 @@
     'eureka dongle': CAST_TYPE_CHROMECAST,
     'chromecast audio': CAST_TYPE_AUDIO,
     'google home': CAST_TYPE_AUDIO,
+    'google home mini': CAST_TYPE_AUDIO,
     'google cast group': CAST_TYPE_GROUP,
 }
 
@@ -40,6 +41,38 @@
                     data='{"params":"now"}', timeout=10)
 
 
+def _get_status(host, services, zconf, path):
+    """
+    :param host: Hostname or ip to fetch status from
+    :type host: str
+    :return: The device status as a named tuple.
+    :rtype: pychromecast.dial.DeviceStatus or None
+    """
+
+    if not host:
+        for service in services.copy():
+            service_info = get_info_from_service(service, zconf)
+            host, _ = get_host_from_service_info(service_info)
+            if host:
+                _LOGGER.debug("Resolved service %s to %s", service, host)
+                break
+
+    req = CC_SESSION.get(FORMAT_BASE_URL.format(host) + path, timeout=10)
+
+    req.raise_for_status()
+
+    # The Requests library will fall back to guessing the encoding in case
+    # no encoding is specified in the response headers - which is the case
+    # for the Chromecast.
+    # The standard mandates utf-8 encoding, let's fall back to that instead
+    # if no encoding is provided, since the autodetection does not always
+    # provide correct results.
+    if req.encoding is None:
+        req.encoding = 'utf-8'
+
+    return req.json()
+
+
 def get_device_status(host, services=None, zconf=None):
     """
     :param host: Hostname or ip to fetch status from
@@ -49,30 +82,8 @@
     """
 
     try:
-        if not host:
-            for service in services.copy():
-                service_info = get_info_from_service(service, zconf)
-                host, _ = get_host_from_service_info(service_info)
-                if host:
-                    _LOGGER.debug("Resolved service %s to %s", service, host)
-                    break
-
-        req = CC_SESSION.get(
-            FORMAT_BASE_URL.format(host) + "/setup/eureka_info?options=detail",
-            timeout=10)
-
-        req.raise_for_status()
-
-        # The Requests library will fall back to guessing the encoding in case
-        # no encoding is specified in the response headers - which is the case
-        # for the Chromecast.
-        # The standard mandates utf-8 encoding, let's fall back to that instead
-        # if no encoding is provided, since the autodetection does not always
-        # provide correct results.
-        if req.encoding is None:
-            req.encoding = 'utf-8'
-
-        status = req.json()
+        status = _get_status(
+            host, services, zconf, "/setup/eureka_info?options=detail")
 
         friendly_name = status.get('name', "Unknown Chromecast")
         model_name = "Unknown model name"
@@ -97,6 +108,52 @@
         return None
 
 
+def get_multizone_status(host, services=None, zconf=None):
+    """
+    :param host: Hostname or ip to fetch status from
+    :type host: str
+    :return: The multizone status as a named tuple.
+    :rtype: pychromecast.dial.MultizoneStatus or None
+    """
+
+    try:
+        status = status = _get_status(
+            host, services, zconf, "/setup/eureka_info?params=multizone")
+
+        dynamic_groups = []
+        if 'multizone' in status and 'dynamic_groups' in status['multizone']:
+            for group in status['multizone']['dynamic_groups']:
+                name = group.get('name', "Unknown group name")
+                udn = group.get('uuid', None)
+                uuid = None
+                if udn:
+                    uuid = UUID(udn.replace('-', ''))
+                dynamic_groups.append(MultizoneInfo(name, uuid))
+
+        groups = []
+        if 'multizone' in status and 'groups' in status['multizone']:
+            for group in status['multizone']['groups']:
+                name = group.get('name', "Unknown group name")
+                udn = group.get('uuid', None)
+                uuid = None
+                if udn:
+                    uuid = UUID(udn.replace('-', ''))
+                groups.append(MultizoneInfo(name, uuid))
+
+        return MultizoneStatus(dynamic_groups, groups)
+
+    except (requests.exceptions.RequestException, OSError, ValueError):
+        return None
+
+
 DeviceStatus = namedtuple(
     "DeviceStatus",
     ["friendly_name", "model_name", "manufacturer", "uuid", "cast_type"])
+
+MultizoneInfo = namedtuple(
+    "MultizoneInfo",
+    ["friendly_name", "uuid"])
+
+MultizoneStatus = namedtuple(
+    "MultizoneStatus",
+    ["dynamic_groups", "groups"])
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/pychromecast/socket_client.py 
new/PyChromecast-3.2.2/pychromecast/socket_client.py
--- old/PyChromecast-2.5.2/pychromecast/socket_client.py        2019-02-16 
20:43:30.000000000 +0100
+++ new/PyChromecast-3.2.2/pychromecast/socket_client.py        2019-04-27 
12:39:22.000000000 +0200
@@ -212,14 +212,6 @@
 
         self.receiver_controller.register_status_listener(self)
 
-        try:
-            self.initialize_connection()
-        except ChromecastConnectionError:
-            self._report_connection_status(
-                ConnectionStatus(CONNECTION_STATUS_DISCONNECTED,
-                                 NetworkAddress(self.host, self.port)))
-            raise
-
     def initialize_connection(self):  # noqa: E501 
pylint:disable=too-many-statements, too-many-branches
         """Initialize a socket to a Chromecast, retrying as necessary."""
         tries = self.tries
@@ -361,6 +353,19 @@
                           self.fn or self.host, self.port)
         raise ChromecastConnectionError("Failed to connect")
 
+    def connect(self):
+        """ Connect socket connection to Chromecast device.
+
+            Must only be called if the worker thread will not be started.
+        """
+        try:
+            self.initialize_connection()
+        except ChromecastConnectionError:
+            self._report_connection_status(
+                ConnectionStatus(CONNECTION_STATUS_DISCONNECTED,
+                                 NetworkAddress(self.host, self.port)))
+            return
+
     def disconnect(self):
         """ Disconnect socket connection to Chromecast device """
         self.stop.set()
@@ -413,7 +418,15 @@
         return self.stop.is_set()
 
     def run(self):
-        """ Start polling the socket. """
+        """ Connect to the cast and start polling the socket. """
+        try:
+            self.initialize_connection()
+        except ChromecastConnectionError:
+            self._report_connection_status(
+                ConnectionStatus(CONNECTION_STATUS_DISCONNECTED,
+                                 NetworkAddress(self.host, self.port)))
+            return
+
         self.heartbeat_controller.reset()
         self._force_recon = False
         logging.debug("Thread started...")
@@ -522,6 +535,8 @@
             reset = True
 
         if reset:
+            for channel in self._open_channels:
+                self.disconnect_channel(channel)
             self._report_connection_status(
                 ConnectionStatus(CONNECTION_STATUS_LOST,
                                  NetworkAddress(self.host, self.port)))
@@ -601,7 +616,9 @@
                                   id(listener), type(listener).__name__)
                 listener.new_connection_status(status)
             except Exception:  # pylint: disable=broad-except
-                pass
+                self.logger.exception(
+                    "[%s:%s] Exception thrown when calling connection "
+                    "listener", self.fn or self.host, self.port)
 
     def _read_bytes_from_socket(self, msglen):
         """ Read bytes from the socket. """
@@ -698,8 +715,8 @@
                 self.logger.info('[%s:%s] Error writing to socket.',
                                  self.fn or self.host, self.port)
         else:
-            raise NotConnected("Chromecast " + self.host + ":" + self.port +
-                               " is connecting...")
+            raise NotConnected("Chromecast " + self.host + ":" +
+                               str(self.port) + " is connecting...")
 
     def send_platform_message(self, namespace, message, inc_session_id=False,
                               callback_function_param=False):
@@ -747,10 +764,16 @@
     def disconnect_channel(self, destination_id):
         """ Disconnect a channel with destination_id. """
         if destination_id in self._open_channels:
-            self.send_message(
-                destination_id, NS_CONNECTION,
-                {MESSAGE_TYPE: TYPE_CLOSE, 'origin': {}},
-                no_add_request_id=True, force=True)
+            try:
+                self.send_message(
+                    destination_id, NS_CONNECTION,
+                    {MESSAGE_TYPE: TYPE_CLOSE, 'origin': {}},
+                    no_add_request_id=True, force=True)
+            except NotConnected:
+                pass
+            except Exception:  # pylint: disable=broad-except
+                self.logger.exception("[%s:%s] Exception",
+                                      self.fn or self.host, self.port)
 
             self._open_channels.remove(destination_id)
 
@@ -1024,7 +1047,8 @@
             try:
                 listener.new_cast_status(self.status)
             except Exception:  # pylint: disable=broad-except
-                pass
+                self.logger.exception(
+                    "Exception thrown when calling cast status listener")
 
     @staticmethod
     def _parse_launch_error(data):
@@ -1057,7 +1081,8 @@
             try:
                 listener.new_launch_error(launch_failure)
             except Exception:  # pylint: disable=broad-except
-                pass
+                self.logger.exception(
+                    "Exception thrown when calling launch error listener")
 
     def tear_down(self):
         """ Called when controller is destroyed. """
@@ -1067,7 +1092,6 @@
         self.launch_failure = None
         self.app_to_launch = None
         self.app_launch_event.clear()
-        self._report_status()
 
         self._status_listeners[:] = []
 
diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' 
'--exclude=.svnignore' old/PyChromecast-2.5.2/setup.py 
new/PyChromecast-3.2.2/setup.py
--- old/PyChromecast-2.5.2/setup.py     2019-02-16 20:43:34.000000000 +0100
+++ new/PyChromecast-3.2.2/setup.py     2019-05-13 19:46:26.000000000 +0200
@@ -5,7 +5,7 @@
 
 setup(
     name='PyChromecast',
-    version='2.5.2',
+    version='3.2.2',
     license='MIT',
     url='https://github.com/balloob/pychromecast',
     author='Paulus Schoutsen',


Reply via email to