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.
Files changed (55) hide show
  1. {pychromecast-14.0.9 → pychromecast-14.1.0}/PKG-INFO +2 -2
  2. {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/PKG-INFO +2 -2
  3. {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/SOURCES.txt +6 -1
  4. {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/requires.txt +1 -1
  5. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/__init__.py +8 -4
  6. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/const.py +5 -0
  7. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/media.py +3 -3
  8. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/multizone.py +154 -3
  9. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/receiver.py +1 -1
  10. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/dial.py +110 -30
  11. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/discovery.py +15 -7
  12. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/error.py +4 -0
  13. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/models.py +1 -0
  14. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/socket_client.py +81 -193
  15. {pychromecast-14.0.9 → pychromecast-14.1.0}/pyproject.toml +16 -5
  16. pychromecast-14.1.0/requirements.txt +3 -0
  17. pychromecast-14.1.0/tests/test_dial.py +148 -0
  18. pychromecast-14.1.0/tests/test_init.py +8 -0
  19. pychromecast-14.1.0/tests/test_multizone.py +238 -0
  20. pychromecast-14.1.0/tests/test_new_cast_status.py +107 -0
  21. pychromecast-14.1.0/tests/test_socket_client.py +229 -0
  22. pychromecast-14.0.9/requirements.txt +0 -3
  23. {pychromecast-14.0.9 → pychromecast-14.1.0}/LICENSE +0 -0
  24. {pychromecast-14.0.9 → pychromecast-14.1.0}/MANIFEST.in +0 -0
  25. {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/dependency_links.txt +0 -0
  26. {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/not-zip-safe +0 -0
  27. {pychromecast-14.0.9 → pychromecast-14.1.0}/PyChromecast.egg-info/top_level.txt +0 -0
  28. {pychromecast-14.0.9 → pychromecast-14.1.0}/README.rst +0 -0
  29. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/config.py +0 -0
  30. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/__init__.py +0 -0
  31. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/bbciplayer.py +0 -0
  32. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/bbcsounds.py +0 -0
  33. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/bubbleupnp.py +0 -0
  34. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/dashcast.py +0 -0
  35. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/heartbeat.py +0 -0
  36. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/homeassistant.py +0 -0
  37. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/homeassistant_media.py +0 -0
  38. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/nrkradio.py +0 -0
  39. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/nrktv.py +0 -0
  40. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/plex.py +0 -0
  41. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/shaka.py +0 -0
  42. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/supla.py +0 -0
  43. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/yleareena.py +0 -0
  44. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/controllers/youtube.py +0 -0
  45. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/authority_keys_pb2.py +0 -0
  46. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/authority_keys_pb2.pyi +0 -0
  47. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/cast_channel_pb2.py +0 -0
  48. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/cast_channel_pb2.pyi +0 -0
  49. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/logging_pb2.py +0 -0
  50. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/logging_pb2.pyi +0 -0
  51. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/generated/readme.txt +0 -0
  52. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/py.typed +0 -0
  53. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/quick_play.py +0 -0
  54. {pychromecast-14.0.9 → pychromecast-14.1.0}/pychromecast/response_handler.py +0 -0
  55. {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.9
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.25.1
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.9
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.25.1
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
@@ -1,3 +1,3 @@
1
1
  protobuf>=4.25.1
2
- zeroconf>=0.25.1
2
+ zeroconf>=0.128.1
3
3
  casttube>=0.2.0
@@ -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 zeroconf.Zeroconf()
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(known_hosts=known_hosts)
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 zeroconf.Zeroconf()
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 not self.status.is_active_input
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.utcnow() - self.last_updated).total_seconds()
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.utcnow()
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 typing import TYPE_CHECKING, TypedDict
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
- # A temporary group has been formed
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", None if is_audio else False),
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 _get_host_from_zc_service_info(service_info) + (service_info,)
61
+ return get_host_from_zc_service_info(service_info) + (service_info,)
60
62
 
61
63
 
62
- def _get_host_from_zc_service_info(
64
+ def get_host_from_zc_service_info(
63
65
  service_info: zeroconf.ServiceInfo | None,
64
66
  ) -> tuple[str | None, int | None]:
65
- """Get hostname or IP + port from zeroconf service_info."""
66
- host = None
67
- port = None
68
- if service_info and service_info.port:
69
- if len(service_info.addresses) > 0:
70
- host = socket.inet_ntoa(service_info.addresses[0])
71
- elif service_info.server is not None:
72
- host = service_info.server.lower()
73
- if host is not None:
74
- port = service_info.port
75
- return (host, port)
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(host) + path
153
+ url = FORMAT_BASE_URL_HTTPS.format(host_encoded) + path
118
154
  else:
119
- url = FORMAT_BASE_URL_HTTP.format(host) + path
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
- host, status = _get_status(
155
- cast_info.services,
156
- zconf,
157
- "/setup/eureka_info?params=device_info,name",
158
- True,
159
- timeout,
160
- context,
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 len(elected_leader_split) == 2:
367
+ elif ":" in elected_leader:
290
368
  # The port in the URL is not useful, but we can scan the host
291
- leader_host = elected_leader_split[0]
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 get_device_info, get_multizone_status, get_ssl_context
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 = None
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
- addresses = service.parsed_addresses()
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 zeroconf.Zeroconf()
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 zeroconf.Zeroconf()
778
+ zconf = zeroconf_instance or create_zeroconf()
771
779
  browser = CastBrowser(SimpleCastListener(add_callback), zconf, known_hosts)
772
780
  browser.start_discovery()
773
781