PyChromecast 14.0.9__tar.gz → 14.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PKG-INFO +2 -2
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/PKG-INFO +2 -2
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/SOURCES.txt +6 -1
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/requires.txt +1 -1
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/__init__.py +8 -4
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/const.py +5 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/media.py +3 -3
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/multizone.py +154 -3
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/receiver.py +1 -1
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/dial.py +110 -30
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/discovery.py +15 -7
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/error.py +4 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/models.py +1 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/socket_client.py +81 -193
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pyproject.toml +16 -5
- pychromecast-14.1.0/requirements.txt +3 -0
- pychromecast-14.1.0/tests/test_dial.py +148 -0
- pychromecast-14.1.0/tests/test_init.py +8 -0
- pychromecast-14.1.0/tests/test_multizone.py +238 -0
- pychromecast-14.1.0/tests/test_new_cast_status.py +107 -0
- pychromecast-14.1.0/tests/test_socket_client.py +229 -0
- pychromecast-14.0.9/requirements.txt +0 -3
- {pychromecast-14.0.9 → pychromecast-14.1.0}/LICENSE +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/MANIFEST.in +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/dependency_links.txt +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/not-zip-safe +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/top_level.txt +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/README.rst +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/config.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/__init__.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/bbciplayer.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/bbcsounds.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/bubbleupnp.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/dashcast.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/heartbeat.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/homeassistant.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/homeassistant_media.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/nrkradio.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/nrktv.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/plex.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/shaka.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/supla.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/yleareena.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/youtube.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/authority_keys_pb2.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/authority_keys_pb2.pyi +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/cast_channel_pb2.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/cast_channel_pb2.pyi +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/logging_pb2.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/logging_pb2.pyi +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/readme.txt +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/py.typed +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/quick_play.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/response_handler.py +0 -0
- {pychromecast-14.0.9 → pychromecast-14.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyChromecast
|
|
3
|
-
Version: 14.0
|
|
3
|
+
Version: 14.1.0
|
|
4
4
|
Summary: Python module to talk to Google Chromecast.
|
|
5
5
|
Author-email: Paulus Schoutsen <paulus@paulusschoutsen.nl>
|
|
6
6
|
License: MIT
|
|
@@ -17,7 +17,7 @@ Requires-Python: >=3.11.0
|
|
|
17
17
|
Description-Content-Type: text/x-rst
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: protobuf>=4.25.1
|
|
20
|
-
Requires-Dist: zeroconf>=0.
|
|
20
|
+
Requires-Dist: zeroconf>=0.128.1
|
|
21
21
|
Requires-Dist: casttube>=0.2.0
|
|
22
22
|
Dynamic: license-file
|
|
23
23
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: PyChromecast
|
|
3
|
-
Version: 14.0
|
|
3
|
+
Version: 14.1.0
|
|
4
4
|
Summary: Python module to talk to Google Chromecast.
|
|
5
5
|
Author-email: Paulus Schoutsen <paulus@paulusschoutsen.nl>
|
|
6
6
|
License: MIT
|
|
@@ -17,7 +17,7 @@ Requires-Python: >=3.11.0
|
|
|
17
17
|
Description-Content-Type: text/x-rst
|
|
18
18
|
License-File: LICENSE
|
|
19
19
|
Requires-Dist: protobuf>=4.25.1
|
|
20
|
-
Requires-Dist: zeroconf>=0.
|
|
20
|
+
Requires-Dist: zeroconf>=0.128.1
|
|
21
21
|
Requires-Dist: casttube>=0.2.0
|
|
22
22
|
Dynamic: license-file
|
|
23
23
|
|
|
@@ -45,4 +45,9 @@ pychromecast/generated/cast_channel_pb2.py
|
|
|
45
45
|
pychromecast/generated/cast_channel_pb2.pyi
|
|
46
46
|
pychromecast/generated/logging_pb2.py
|
|
47
47
|
pychromecast/generated/logging_pb2.pyi
|
|
48
|
-
pychromecast/generated/readme.txt
|
|
48
|
+
pychromecast/generated/readme.txt
|
|
49
|
+
tests/test_dial.py
|
|
50
|
+
tests/test_init.py
|
|
51
|
+
tests/test_multizone.py
|
|
52
|
+
tests/test_new_cast_status.py
|
|
53
|
+
tests/test_socket_client.py
|
|
@@ -22,6 +22,7 @@ from .discovery import ( # noqa: F401
|
|
|
22
22
|
CastBrowser,
|
|
23
23
|
CastListener, # Deprecated
|
|
24
24
|
SimpleCastListener,
|
|
25
|
+
create_zeroconf,
|
|
25
26
|
discover_chromecasts,
|
|
26
27
|
start_discovery,
|
|
27
28
|
stop_discovery,
|
|
@@ -163,7 +164,7 @@ def get_listed_chromecasts(
|
|
|
163
164
|
|
|
164
165
|
discover_complete = Event()
|
|
165
166
|
|
|
166
|
-
zconf = zeroconf_instance or
|
|
167
|
+
zconf = zeroconf_instance or create_zeroconf()
|
|
167
168
|
browser = CastBrowser(SimpleCastListener(add_callback), zconf, known_hosts)
|
|
168
169
|
browser.start_discovery()
|
|
169
170
|
|
|
@@ -238,7 +239,10 @@ def get_chromecasts( # pylint: disable=too-many-locals
|
|
|
238
239
|
"""
|
|
239
240
|
if blocking:
|
|
240
241
|
# Thread blocking chromecast discovery
|
|
241
|
-
devices, browser = discover_chromecasts(
|
|
242
|
+
devices, browser = discover_chromecasts(
|
|
243
|
+
zeroconf_instance=zeroconf_instance,
|
|
244
|
+
known_hosts=known_hosts,
|
|
245
|
+
)
|
|
242
246
|
cc_list: list[Chromecast] = []
|
|
243
247
|
for device in devices:
|
|
244
248
|
try:
|
|
@@ -281,7 +285,7 @@ def get_chromecasts( # pylint: disable=too-many-locals
|
|
|
281
285
|
except ChromecastConnectionError: # noqa: F405
|
|
282
286
|
pass
|
|
283
287
|
|
|
284
|
-
zconf = zeroconf_instance or
|
|
288
|
+
zconf = zeroconf_instance or create_zeroconf()
|
|
285
289
|
browser = CastBrowser(SimpleCastListener(add_callback), zconf, known_hosts)
|
|
286
290
|
browser.start_discovery()
|
|
287
291
|
return browser
|
|
@@ -371,7 +375,7 @@ class Chromecast(CastStatusListener):
|
|
|
371
375
|
or self.app_id in (None, IDLE_APP_ID)
|
|
372
376
|
or (
|
|
373
377
|
self.cast_type == CAST_TYPE_CHROMECAST
|
|
374
|
-
and
|
|
378
|
+
and self.status.is_active_input is False
|
|
375
379
|
and not self.ignore_cec
|
|
376
380
|
)
|
|
377
381
|
)
|
|
@@ -46,6 +46,8 @@ CAST_TYPES = {
|
|
|
46
46
|
"google nest mini": (CAST_TYPE_AUDIO, MF_GOOGLE),
|
|
47
47
|
"nest audio": (CAST_TYPE_AUDIO, MF_GOOGLE),
|
|
48
48
|
"nest wifi point": (CAST_TYPE_AUDIO, MF_GOOGLE),
|
|
49
|
+
"bravia 4k gb": (CAST_TYPE_CHROMECAST, MF_SONY),
|
|
50
|
+
"bravia 4k gb atv3": (CAST_TYPE_CHROMECAST, MF_SONY),
|
|
49
51
|
"bravia 4k vh2": (CAST_TYPE_CHROMECAST, MF_SONY),
|
|
50
52
|
"bravia 4k vh21": (CAST_TYPE_CHROMECAST, MF_SONY),
|
|
51
53
|
"bose smart ultra soundbar": (CAST_TYPE_AUDIO, MF_BOSE),
|
|
@@ -55,8 +57,10 @@ CAST_TYPES = {
|
|
|
55
57
|
"jbl link 300": (CAST_TYPE_AUDIO, MF_JBL),
|
|
56
58
|
"jbl link 500": (CAST_TYPE_AUDIO, MF_JBL),
|
|
57
59
|
"jbl link portable": (CAST_TYPE_AUDIO, MF_HARMAN),
|
|
60
|
+
"jbl playlist": (CAST_TYPE_AUDIO, MF_JBL),
|
|
58
61
|
"lenovocd-24502f": (CAST_TYPE_AUDIO, MF_LENOVO),
|
|
59
62
|
"lenovo smart display 7": (CAST_TYPE_CHROMECAST, MF_LENOVO),
|
|
63
|
+
"lenovo smart display 10": (CAST_TYPE_CHROMECAST, MF_LENOVO),
|
|
60
64
|
"lg wk7 thinq speaker": (CAST_TYPE_AUDIO, MF_LG),
|
|
61
65
|
"marshall stanmore ii": (CAST_TYPE_AUDIO, MF_MARSHALL),
|
|
62
66
|
"mitv-mssp2": (CAST_TYPE_CHROMECAST, MF_XIAOMI),
|
|
@@ -69,6 +73,7 @@ CAST_TYPES = {
|
|
|
69
73
|
"svs pro soundbase": (CAST_TYPE_AUDIO, MF_SVS),
|
|
70
74
|
"tpm191e": (CAST_TYPE_CHROMECAST, MF_PHILIPS),
|
|
71
75
|
"v705-h3": (CAST_TYPE_CHROMECAST, MF_VIZIO),
|
|
76
|
+
"d24f-j09": (CAST_TYPE_CHROMECAST, MF_VIZIO),
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
|
|
@@ -4,7 +4,7 @@ on the Chromecast.
|
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
6
|
import abc
|
|
7
|
-
from datetime import datetime
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
8
|
from dataclasses import dataclass
|
|
9
9
|
import logging
|
|
10
10
|
import threading
|
|
@@ -153,7 +153,7 @@ class MediaStatus:
|
|
|
153
153
|
return (
|
|
154
154
|
self.current_time
|
|
155
155
|
+ self.playback_rate
|
|
156
|
-
* (datetime.
|
|
156
|
+
* (datetime.now(timezone.utc) - self.last_updated).total_seconds()
|
|
157
157
|
)
|
|
158
158
|
# Not playing, return last reported seek time
|
|
159
159
|
return self.current_time
|
|
@@ -336,7 +336,7 @@ class MediaStatus:
|
|
|
336
336
|
self.current_subtitle_tracks = status_data.get(
|
|
337
337
|
"activeTrackIds", self.current_subtitle_tracks
|
|
338
338
|
)
|
|
339
|
-
self.last_updated = datetime.
|
|
339
|
+
self.last_updated = datetime.now(timezone.utc)
|
|
340
340
|
|
|
341
341
|
def __repr__(self) -> str:
|
|
342
342
|
info = {
|
|
@@ -6,13 +6,16 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
import abc
|
|
8
8
|
import logging
|
|
9
|
-
from
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import TYPE_CHECKING, Any, TypedDict
|
|
10
12
|
from uuid import UUID
|
|
11
13
|
|
|
12
|
-
from . import BaseController
|
|
14
|
+
from . import BaseController, CallbackType
|
|
13
15
|
from .media import MediaController, MediaStatusListener, MediaStatus
|
|
14
16
|
from .receiver import CastStatus, CastStatusListener
|
|
15
17
|
from ..const import MESSAGE_TYPE
|
|
18
|
+
from ..models import CastInfo
|
|
16
19
|
|
|
17
20
|
# pylint: disable-next=no-name-in-module
|
|
18
21
|
from ..generated.cast_channel_pb2 import CastMessage
|
|
@@ -38,6 +41,73 @@ TYPE_GET_CASTING_GROUPS = "GET_CASTING_GROUPS"
|
|
|
38
41
|
TYPE_GET_STATUS = "GET_STATUS"
|
|
39
42
|
TYPE_MULTIZONE_STATUS = "MULTIZONE_STATUS"
|
|
40
43
|
TYPE_SESSION_UPDATED = "PLAYBACK_SESSION_UPDATED"
|
|
44
|
+
TYPE_SET_PLAYBACK_DEVICES = "SET_PLAYBACK_DEVICES"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PlaybackSession:
|
|
49
|
+
"""Capabilities of the playback session attached to a multizone endpoint."""
|
|
50
|
+
|
|
51
|
+
app_allows_grouping: bool | None
|
|
52
|
+
is_video_content: bool | None
|
|
53
|
+
stream_transfer_supported: bool | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class SessionEndpoint:
|
|
58
|
+
"""Endpoint which currently owns a playback session."""
|
|
59
|
+
|
|
60
|
+
device_id: str
|
|
61
|
+
host: str
|
|
62
|
+
port: int
|
|
63
|
+
session_id: str | None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class PlaybackSessionUpdate:
|
|
68
|
+
"""Change of the set of devices playing a session.
|
|
69
|
+
|
|
70
|
+
changed_devices may be empty, it's always empty on copies sent to other
|
|
71
|
+
connections than the one which made the change.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
name: str
|
|
75
|
+
changed_devices: list[str]
|
|
76
|
+
failed_devices: list[str]
|
|
77
|
+
session_endpoint: SessionEndpoint | None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _normalize_device_id(device_id: str) -> str:
|
|
81
|
+
"""Return device id in dashed form, devices report both forms."""
|
|
82
|
+
try:
|
|
83
|
+
return str(UUID(device_id))
|
|
84
|
+
except ValueError:
|
|
85
|
+
return device_id
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _parse_playback_session(data: dict[str, Any]) -> PlaybackSession:
|
|
89
|
+
return PlaybackSession(
|
|
90
|
+
data.get("appAllowsGrouping"),
|
|
91
|
+
data.get("isVideoContent"),
|
|
92
|
+
data.get("streamTransferSupported"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _parse_session_update(data: dict[str, Any]) -> PlaybackSessionUpdate:
|
|
97
|
+
endpoint = None
|
|
98
|
+
if endpoint_data := data.get("sessionEndpoint"):
|
|
99
|
+
endpoint = SessionEndpoint(
|
|
100
|
+
_normalize_device_id(endpoint_data["deviceId"]),
|
|
101
|
+
endpoint_data["ipAddress"],
|
|
102
|
+
endpoint_data["port"],
|
|
103
|
+
endpoint_data.get("sessionId"),
|
|
104
|
+
)
|
|
105
|
+
return PlaybackSessionUpdate(
|
|
106
|
+
data.get("name", ""),
|
|
107
|
+
[_normalize_device_id(uuid) for uuid in data.get("changedDevices", [])],
|
|
108
|
+
[_normalize_device_id(uuid) for uuid in data.get("failedDevices", [])],
|
|
109
|
+
endpoint,
|
|
110
|
+
)
|
|
41
111
|
|
|
42
112
|
|
|
43
113
|
class GroupInfo(TypedDict):
|
|
@@ -69,6 +139,9 @@ class MultiZoneControllerListener(abc.ABC):
|
|
|
69
139
|
def multizone_status_received(self) -> None:
|
|
70
140
|
"""Multizone status has been updated."""
|
|
71
141
|
|
|
142
|
+
def multizone_playback_session_updated(self, update: PlaybackSessionUpdate) -> None:
|
|
143
|
+
"""The set of devices playing the session has changed."""
|
|
144
|
+
|
|
72
145
|
|
|
73
146
|
class MultiZoneManagerListener(abc.ABC):
|
|
74
147
|
"""Listener for receiving audio group events for a cast device."""
|
|
@@ -235,6 +308,7 @@ class MultizoneController(BaseController):
|
|
|
235
308
|
|
|
236
309
|
def __init__(self, uuid: UUID) -> None:
|
|
237
310
|
self._members: dict[str, str] = {}
|
|
311
|
+
self._playback_session: PlaybackSession | None = None
|
|
238
312
|
self._status_listeners: list[MultiZoneControllerListener] = []
|
|
239
313
|
self._uuid = str(uuid)
|
|
240
314
|
super().__init__(MULTIZONE_NAMESPACE, target_platform=True)
|
|
@@ -269,6 +343,7 @@ class MultizoneController(BaseController):
|
|
|
269
343
|
listener.multizone_member_added(uuid)
|
|
270
344
|
listener.multizone_member_removed(uuid)
|
|
271
345
|
listener.multizone_status_received()
|
|
346
|
+
listener.multizone_playback_session_updated(update)
|
|
272
347
|
"""
|
|
273
348
|
self._status_listeners.append(listener)
|
|
274
349
|
|
|
@@ -277,8 +352,17 @@ class MultizoneController(BaseController):
|
|
|
277
352
|
"""Return a list of audio group members."""
|
|
278
353
|
return list(self._members.keys())
|
|
279
354
|
|
|
355
|
+
@property
|
|
356
|
+
def playback_session(self) -> PlaybackSession | None:
|
|
357
|
+
"""Return the playback session reported in the last multizone status.
|
|
358
|
+
|
|
359
|
+
None if no session is attached to this endpoint.
|
|
360
|
+
"""
|
|
361
|
+
return self._playback_session
|
|
362
|
+
|
|
280
363
|
def reset_members(self) -> None:
|
|
281
364
|
"""Reset audio group members."""
|
|
365
|
+
self._playback_session = None
|
|
282
366
|
for uuid in list(self._members):
|
|
283
367
|
self._remove_member(uuid)
|
|
284
368
|
|
|
@@ -290,6 +374,61 @@ class MultizoneController(BaseController):
|
|
|
290
374
|
"""Send GET_CASTING_GROUPS message."""
|
|
291
375
|
self.send_message({MESSAGE_TYPE: TYPE_GET_CASTING_GROUPS})
|
|
292
376
|
|
|
377
|
+
def set_playback_devices(
|
|
378
|
+
self,
|
|
379
|
+
session_id: str,
|
|
380
|
+
devices: Iterable[CastInfo],
|
|
381
|
+
*,
|
|
382
|
+
session_name: str | None = None,
|
|
383
|
+
callback_function: CallbackType | None = None,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Set the devices playing a session, without interrupting playback.
|
|
386
|
+
|
|
387
|
+
Must be sent to the endpoint which currently owns the session, devices
|
|
388
|
+
is the complete set of devices which should play the session. The
|
|
389
|
+
session may move to another endpoint, see
|
|
390
|
+
PlaybackSessionUpdate.session_endpoint in the response.
|
|
391
|
+
|
|
392
|
+
Depending on the app, moving the session to devices which aren't
|
|
393
|
+
playing it may restart playback on them. Adding the new devices first
|
|
394
|
+
and then removing the old ones doesn't.
|
|
395
|
+
|
|
396
|
+
The callback is called with True and the PLAYBACK_SESSION_UPDATED
|
|
397
|
+
response if all devices were changed, else with False and the response,
|
|
398
|
+
for example INVALID_REQUEST if this isn't the session's endpoint.
|
|
399
|
+
"""
|
|
400
|
+
device_list = [
|
|
401
|
+
{
|
|
402
|
+
"deviceId": str(device.uuid),
|
|
403
|
+
"ipAddress": device.host,
|
|
404
|
+
"port": device.port,
|
|
405
|
+
}
|
|
406
|
+
for device in devices
|
|
407
|
+
]
|
|
408
|
+
if not device_list:
|
|
409
|
+
raise ValueError("A session must play on at least one device")
|
|
410
|
+
|
|
411
|
+
message: dict[str, Any] = {
|
|
412
|
+
MESSAGE_TYPE: TYPE_SET_PLAYBACK_DEVICES,
|
|
413
|
+
"sessionId": session_id,
|
|
414
|
+
"deviceList": device_list,
|
|
415
|
+
}
|
|
416
|
+
if session_name is not None:
|
|
417
|
+
message["sessionName"] = session_name
|
|
418
|
+
|
|
419
|
+
def handle_response(msg_sent: bool, response: dict | None) -> None:
|
|
420
|
+
if not callback_function:
|
|
421
|
+
return
|
|
422
|
+
callback_function(
|
|
423
|
+
msg_sent
|
|
424
|
+
and response is not None
|
|
425
|
+
and response.get(MESSAGE_TYPE) == TYPE_SESSION_UPDATED
|
|
426
|
+
and not response.get("failedDevices"),
|
|
427
|
+
response,
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
self.send_message(message, callback_function=handle_response)
|
|
431
|
+
|
|
293
432
|
def receive_message( # pylint: disable=too-many-return-statements
|
|
294
433
|
self, _message: CastMessage, data: dict
|
|
295
434
|
) -> bool:
|
|
@@ -328,13 +467,25 @@ class MultizoneController(BaseController):
|
|
|
328
467
|
for uuid in added_members:
|
|
329
468
|
self._add_member(uuid, members[uuid])
|
|
330
469
|
|
|
470
|
+
playback_session = data["status"].get("playbackSession")
|
|
471
|
+
self._playback_session = (
|
|
472
|
+
_parse_playback_session(playback_session)
|
|
473
|
+
if playback_session is not None
|
|
474
|
+
else None
|
|
475
|
+
)
|
|
476
|
+
|
|
331
477
|
for listener in list(self._status_listeners):
|
|
332
478
|
listener.multizone_status_received()
|
|
333
479
|
|
|
334
480
|
return True
|
|
335
481
|
|
|
336
482
|
if data[MESSAGE_TYPE] == TYPE_SESSION_UPDATED:
|
|
337
|
-
#
|
|
483
|
+
# The devices playing the session changed, the session may have
|
|
484
|
+
# moved to a new endpoint
|
|
485
|
+
update = _parse_session_update(data)
|
|
486
|
+
_LOGGER.debug("(%s) Playback session updated: %s", self._uuid, update)
|
|
487
|
+
for listener in list(self._status_listeners):
|
|
488
|
+
listener.multizone_playback_session_updated(update)
|
|
338
489
|
return True
|
|
339
490
|
|
|
340
491
|
if data[MESSAGE_TYPE] == TYPE_CASTING_GROUPS:
|
|
@@ -286,7 +286,7 @@ class ReceiverController(BaseController):
|
|
|
286
286
|
is_audio = cast_type in (CAST_TYPE_AUDIO, CAST_TYPE_GROUP)
|
|
287
287
|
|
|
288
288
|
status = CastStatus(
|
|
289
|
-
status_data.get("isActiveInput"
|
|
289
|
+
status_data.get("isActiveInput"),
|
|
290
290
|
status_data.get("isStandBy", None if is_audio else True),
|
|
291
291
|
volume_data.get("level", 1.0),
|
|
292
292
|
volume_data.get("muted", False),
|
|
@@ -6,11 +6,13 @@ from __future__ import annotations
|
|
|
6
6
|
|
|
7
7
|
from dataclasses import dataclass
|
|
8
8
|
from http import HTTPStatus
|
|
9
|
+
import ipaddress
|
|
9
10
|
import json
|
|
10
11
|
import logging
|
|
11
|
-
import socket
|
|
12
12
|
import ssl
|
|
13
13
|
import urllib.request
|
|
14
|
+
import urllib.error
|
|
15
|
+
from urllib.parse import unquote, quote, urlsplit
|
|
14
16
|
from uuid import UUID
|
|
15
17
|
from typing import Any
|
|
16
18
|
|
|
@@ -56,28 +58,29 @@ def get_host_from_service(
|
|
|
56
58
|
# We do not catch zeroconf.NotRunningException as it's
|
|
57
59
|
# an unrecoverable error.
|
|
58
60
|
_LOGGER.debug("get_info_from_service raised:", exc_info=True)
|
|
59
|
-
return
|
|
61
|
+
return get_host_from_zc_service_info(service_info) + (service_info,)
|
|
60
62
|
|
|
61
63
|
|
|
62
|
-
def
|
|
64
|
+
def get_host_from_zc_service_info(
|
|
63
65
|
service_info: zeroconf.ServiceInfo | None,
|
|
64
66
|
) -> tuple[str | None, int | None]:
|
|
65
|
-
"""Get
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
67
|
+
"""Get IP + port from zeroconf service_info.
|
|
68
|
+
|
|
69
|
+
IPv4 addresses are preferred, zeroconf lists them before IPv6 addresses.
|
|
70
|
+
"""
|
|
71
|
+
if not service_info or not service_info.port:
|
|
72
|
+
return None, None
|
|
73
|
+
|
|
74
|
+
addresses = service_info.parsed_scoped_addresses()
|
|
75
|
+
if not addresses:
|
|
76
|
+
return None, None
|
|
77
|
+
|
|
78
|
+
return addresses[0], service_info.port
|
|
76
79
|
|
|
77
80
|
|
|
78
81
|
def _urlopen(url: str, timeout: float, context: ssl.SSLContext | None) -> Any:
|
|
79
82
|
"""Help open an URL."""
|
|
80
|
-
headers = {"content-type": "application/json"}
|
|
83
|
+
headers = {"content-type": "application/json", "host": _url_host_header(url)}
|
|
81
84
|
try:
|
|
82
85
|
req = urllib.request.Request(url, headers=headers)
|
|
83
86
|
return urllib.request.urlopen(req, timeout=timeout, context=context)
|
|
@@ -97,6 +100,32 @@ def _urlopen(url: str, timeout: float, context: ssl.SSLContext | None) -> Any:
|
|
|
97
100
|
return urllib.request.urlopen(req, timeout=timeout, context=context)
|
|
98
101
|
|
|
99
102
|
|
|
103
|
+
def _url_encode_hostname(host: str) -> str:
|
|
104
|
+
"""Ensure hostname is url safe."""
|
|
105
|
+
# any zone identifier must be encoded
|
|
106
|
+
host = host.replace("%", quote("%"))
|
|
107
|
+
|
|
108
|
+
# make sure ipv6 ip address is enclosed
|
|
109
|
+
if host.find(":") >= 0:
|
|
110
|
+
host = f"[{host}]"
|
|
111
|
+
return host
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _url_host_header(url: str) -> str:
|
|
115
|
+
"""Construct host header from url
|
|
116
|
+
|
|
117
|
+
This is needed since urllib.request will mis-construct urls
|
|
118
|
+
that contain ipv6 addresses with zone identifiers and retain
|
|
119
|
+
the scope.
|
|
120
|
+
"""
|
|
121
|
+
parsed = urlsplit(url)
|
|
122
|
+
hostname, _, _ = unquote(parsed.hostname or "").partition("%")
|
|
123
|
+
hostname = _url_encode_hostname(hostname)
|
|
124
|
+
if parsed.port in (80, 443, None):
|
|
125
|
+
return hostname
|
|
126
|
+
return f"{hostname}:{parsed.port}"
|
|
127
|
+
|
|
128
|
+
|
|
100
129
|
def _get_status(
|
|
101
130
|
services: set[HostServiceInfo | MDNSServiceInfo],
|
|
102
131
|
zconf: zeroconf.Zeroconf | None,
|
|
@@ -107,16 +136,23 @@ def _get_status(
|
|
|
107
136
|
) -> tuple[str | None, Any]:
|
|
108
137
|
"""Query a cast device via http(s)."""
|
|
109
138
|
|
|
139
|
+
host = None
|
|
110
140
|
for service in services.copy():
|
|
111
141
|
host, _, _ = get_host_from_service(service, zconf)
|
|
112
142
|
if host:
|
|
113
143
|
_LOGGER.debug("Resolved service %s to %s", service, host)
|
|
114
144
|
break
|
|
115
145
|
|
|
146
|
+
if not host:
|
|
147
|
+
# raise same as the url open would raise on invalid host
|
|
148
|
+
raise urllib.error.URLError(f"Unable to resolve host for services {services}")
|
|
149
|
+
|
|
150
|
+
host_encoded = _url_encode_hostname(host)
|
|
151
|
+
|
|
116
152
|
if secure:
|
|
117
|
-
url = FORMAT_BASE_URL_HTTPS.format(
|
|
153
|
+
url = FORMAT_BASE_URL_HTTPS.format(host_encoded) + path
|
|
118
154
|
else:
|
|
119
|
-
url = FORMAT_BASE_URL_HTTP.format(
|
|
155
|
+
url = FORMAT_BASE_URL_HTTP.format(host_encoded) + path
|
|
120
156
|
|
|
121
157
|
has_context = bool(context)
|
|
122
158
|
if secure and not has_context:
|
|
@@ -148,17 +184,27 @@ def get_cast_type(
|
|
|
148
184
|
cast_type = CAST_TYPE_GROUP
|
|
149
185
|
manufacturer = "Google Inc."
|
|
150
186
|
else:
|
|
151
|
-
host: str | None = "<unknown>"
|
|
152
187
|
try:
|
|
153
188
|
display_supported = True
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
189
|
+
# Try connection with SSL first, and if it fails fall back to non-SSL
|
|
190
|
+
try:
|
|
191
|
+
_, status = _get_status(
|
|
192
|
+
cast_info.services,
|
|
193
|
+
zconf,
|
|
194
|
+
"/setup/eureka_info?params=device_info,name",
|
|
195
|
+
True,
|
|
196
|
+
timeout / 2,
|
|
197
|
+
context,
|
|
198
|
+
)
|
|
199
|
+
except (urllib.error.HTTPError, urllib.error.URLError):
|
|
200
|
+
_, status = _get_status(
|
|
201
|
+
cast_info.services,
|
|
202
|
+
zconf,
|
|
203
|
+
"/setup/eureka_info?params=device_info,name",
|
|
204
|
+
False,
|
|
205
|
+
timeout / 2,
|
|
206
|
+
context,
|
|
207
|
+
)
|
|
162
208
|
if "device_info" in status:
|
|
163
209
|
device_info = status["device_info"]
|
|
164
210
|
|
|
@@ -178,7 +224,7 @@ def get_cast_type(
|
|
|
178
224
|
) as err:
|
|
179
225
|
_LOGGER.warning(
|
|
180
226
|
"Failed to determine cast type for host %s (%s) (services:%s)",
|
|
181
|
-
host,
|
|
227
|
+
cast_info.host,
|
|
182
228
|
err,
|
|
183
229
|
cast_info.services,
|
|
184
230
|
)
|
|
@@ -271,6 +317,39 @@ def get_device_info( # pylint: disable=too-many-locals
|
|
|
271
317
|
return None
|
|
272
318
|
|
|
273
319
|
|
|
320
|
+
def _inherit_scope(address: str, scoped_address: str) -> str:
|
|
321
|
+
"""Give a link local address the scope of scoped_address.
|
|
322
|
+
|
|
323
|
+
A link local address is only reachable through the interface in its scope.
|
|
324
|
+
Group members share a link, so the scope of the polled device applies. A
|
|
325
|
+
scope reported by the device is local to the device and is dropped.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
ip = ipaddress.ip_address(address)
|
|
329
|
+
except ValueError:
|
|
330
|
+
return address
|
|
331
|
+
if not isinstance(ip, ipaddress.IPv6Address) or not ip.is_link_local:
|
|
332
|
+
return address
|
|
333
|
+
address, _, _ = address.partition("%")
|
|
334
|
+
try:
|
|
335
|
+
scoped_ip = ipaddress.ip_address(scoped_address)
|
|
336
|
+
except ValueError:
|
|
337
|
+
return address
|
|
338
|
+
if isinstance(scoped_ip, ipaddress.IPv6Address) and scoped_ip.scope_id:
|
|
339
|
+
return f"{address}%{scoped_ip.scope_id}"
|
|
340
|
+
return address
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _get_leader_host(elected_leader: str) -> str | None:
|
|
344
|
+
"""Get the host from an elected leader in url host:port format."""
|
|
345
|
+
try:
|
|
346
|
+
hostname = urlsplit(f"//{elected_leader}").hostname
|
|
347
|
+
except ValueError:
|
|
348
|
+
_LOGGER.debug("Invalid elected leader %s", elected_leader)
|
|
349
|
+
return None
|
|
350
|
+
return unquote(hostname) if hostname else None
|
|
351
|
+
|
|
352
|
+
|
|
274
353
|
def _get_group_info(host: str, group: Any) -> MultizoneInfo:
|
|
275
354
|
"""Parse group JSON data and return a MultizoneInfo instance."""
|
|
276
355
|
name = group.get("name", "Unknown group name")
|
|
@@ -279,16 +358,17 @@ def _get_group_info(host: str, group: Any) -> MultizoneInfo:
|
|
|
279
358
|
if udn:
|
|
280
359
|
uuid = UUID(udn.replace("-", ""))
|
|
281
360
|
elected_leader = group.get("elected_leader", "")
|
|
282
|
-
elected_leader_split = elected_leader.rsplit(":", 1)
|
|
283
361
|
|
|
284
362
|
leader_host = None
|
|
285
363
|
leader_port = None
|
|
286
364
|
if elected_leader == "self" and "cast_port" in group:
|
|
287
365
|
leader_host = host
|
|
288
366
|
leader_port = group["cast_port"]
|
|
289
|
-
elif
|
|
367
|
+
elif ":" in elected_leader:
|
|
290
368
|
# The port in the URL is not useful, but we can scan the host
|
|
291
|
-
leader_host =
|
|
369
|
+
leader_host = _get_leader_host(elected_leader)
|
|
370
|
+
if leader_host:
|
|
371
|
+
leader_host = _inherit_scope(leader_host, host)
|
|
292
372
|
|
|
293
373
|
return MultizoneInfo(name, uuid, leader_host, leader_port)
|
|
294
374
|
|
|
@@ -15,7 +15,12 @@ from uuid import UUID
|
|
|
15
15
|
import zeroconf
|
|
16
16
|
|
|
17
17
|
from .const import CAST_TYPE_AUDIO, CAST_TYPE_GROUP, CAST_TYPES, MF_GOOGLE
|
|
18
|
-
from .dial import
|
|
18
|
+
from .dial import (
|
|
19
|
+
get_device_info,
|
|
20
|
+
get_multizone_status,
|
|
21
|
+
get_ssl_context,
|
|
22
|
+
get_host_from_zc_service_info,
|
|
23
|
+
)
|
|
19
24
|
from .models import ZEROCONF_ERRORS, CastInfo, HostServiceInfo, MDNSServiceInfo
|
|
20
25
|
|
|
21
26
|
DISCOVER_TIMEOUT = 5
|
|
@@ -121,7 +126,7 @@ class ZeroConfListener(zeroconf.ServiceListener):
|
|
|
121
126
|
_LOGGER.debug("remove_service %s, %s", type_, name)
|
|
122
127
|
cast_info = None
|
|
123
128
|
device_removed = False
|
|
124
|
-
uuid
|
|
129
|
+
uuid: UUID
|
|
125
130
|
service_info = MDNSServiceInfo(name)
|
|
126
131
|
# Lock because the HostBrowser may also add or remove items
|
|
127
132
|
with self._services_lock:
|
|
@@ -200,9 +205,7 @@ class ZeroConfListener(zeroconf.ServiceListener):
|
|
|
200
205
|
return value
|
|
201
206
|
return value.decode("utf-8")
|
|
202
207
|
|
|
203
|
-
|
|
204
|
-
host = addresses[0] if addresses else service.server
|
|
205
|
-
|
|
208
|
+
host, _ = get_host_from_zc_service_info(service)
|
|
206
209
|
if host is None:
|
|
207
210
|
_LOGGER.debug(
|
|
208
211
|
"_add_update_service failed to get host for %s, %s", typ, name
|
|
@@ -686,6 +689,11 @@ def stop_discovery(cast_browser: CastBrowser) -> None:
|
|
|
686
689
|
cast_browser.stop_discovery()
|
|
687
690
|
|
|
688
691
|
|
|
692
|
+
def create_zeroconf() -> zeroconf.Zeroconf:
|
|
693
|
+
"""Create a zeroconf instance doing mDNS over IPv4."""
|
|
694
|
+
return zeroconf.Zeroconf(ip_version=zeroconf.IPVersion.V4Only)
|
|
695
|
+
|
|
696
|
+
|
|
689
697
|
def discover_chromecasts(
|
|
690
698
|
max_devices: int | None = None,
|
|
691
699
|
timeout: float = DISCOVER_TIMEOUT,
|
|
@@ -716,7 +724,7 @@ def discover_chromecasts(
|
|
|
716
724
|
discover_complete.set()
|
|
717
725
|
|
|
718
726
|
discover_complete = threading.Event()
|
|
719
|
-
zconf = zeroconf_instance or
|
|
727
|
+
zconf = zeroconf_instance or create_zeroconf()
|
|
720
728
|
browser = CastBrowser(SimpleCastListener(add_callback), zconf, known_hosts)
|
|
721
729
|
browser.start_discovery()
|
|
722
730
|
|
|
@@ -767,7 +775,7 @@ def discover_listed_chromecasts(
|
|
|
767
775
|
|
|
768
776
|
discover_complete = threading.Event()
|
|
769
777
|
|
|
770
|
-
zconf = zeroconf_instance or
|
|
778
|
+
zconf = zeroconf_instance or create_zeroconf()
|
|
771
779
|
browser = CastBrowser(SimpleCastListener(add_callback), zconf, known_hosts)
|
|
772
780
|
browser.start_discovery()
|
|
773
781
|
|