1 """Helpers to deal with Cast devices."""
3 from __future__
import annotations
6 from dataclasses
import dataclass
8 from typing
import TYPE_CHECKING
9 from urllib.parse
import urlparse
14 from pychromecast
import dial
15 from pychromecast.const
import CAST_TYPE_GROUP
16 from pychromecast.models
import CastInfo
21 from .const
import DOMAIN
27 _LOGGER = logging.getLogger(__name__)
29 _PLS_SECTION_PLAYLIST =
"playlist"
32 @attr.s(slots=True, frozen=True)
34 """Class to hold all data about a chromecast for creating connections.
36 This also has the same attributes as the mDNS fields by zeroconf.
39 cast_info: CastInfo = attr.ib()
40 is_dynamic_group = attr.ib(type=bool |
None, default=
None)
44 """Return the Friendly Name."""
45 return self.cast_info.friendly_name
49 """Return if the cast is an audio group."""
50 return self.cast_info.cast_type == CAST_TYPE_GROUP
54 """Return the UUID."""
55 return self.cast_info.uuid
58 """Return a new ChromecastInfo object with missing attributes filled in.
60 Uses blocking HTTP / HTTPS.
62 cast_info = self.cast_info
63 if self.cast_info.cast_type
is None or self.cast_info.manufacturer
is None:
64 unknown_models = hass.data[DOMAIN][
"unknown_models"]
65 if self.cast_info.model_name
not in unknown_models:
68 cast_info = dial.get_cast_type(
70 zconf=ChromeCastZeroconf.get_zeroconf(),
72 unknown_models[self.cast_info.model_name] = (
74 cast_info.manufacturer,
78 "create a bug report at "
79 "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue"
80 "+label%3A%22integration%3A+cast%22"
85 "Fetched cast details for unknown model '%s' manufacturer:"
86 " '%s', type: '%s'. Please %s"
89 cast_info.manufacturer,
94 cast_type, manufacturer = unknown_models[self.cast_info.model_name]
99 cast_info.friendly_name,
111 is_dynamic_group =
False
112 http_group_status =
None
113 http_group_status = dial.get_multizone_status(
115 services=self.cast_info.services,
116 zconf=ChromeCastZeroconf.get_zeroconf(),
118 if http_group_status
is not None:
119 is_dynamic_group = any(
120 g.uuid == self.cast_info.uuid
for g
in http_group_status.dynamic_groups
125 is_dynamic_group=is_dynamic_group,
130 """Class to hold a zeroconf instance."""
146 pychromecast.controllers.media.MediaStatusListener,
147 pychromecast.controllers.multizone.MultiZoneManagerListener,
148 pychromecast.controllers.receiver.CastStatusListener,
149 pychromecast.socket_client.ConnectionStatusListener,
151 """Helper class to handle pychromecast status callbacks.
153 Necessary because a CastDevice entity or dynamic group can create a new
154 socket client and therefore callbacks from multiple chromecast connections can
155 potentially arrive. This class allows invalidating past chromecast objects.
158 def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False):
159 """Initialize the status listener."""
165 if cast_device._cast_info.is_audio_group:
166 self.
_mz_mgr_mz_mgr.add_multizone(chromecast)
170 chromecast.register_status_listener(self)
171 chromecast.socket_client.media_controller.register_status_listener(self)
172 chromecast.register_connection_listener(self)
173 if not cast_device._cast_info.is_audio_group:
174 self.
_mz_mgr_mz_mgr.register_listener(chromecast.uuid, self)
177 """Handle reception of a new CastStatus."""
182 """Handle reception of a new MediaStatus."""
187 """Handle reception of a new MediaStatus."""
192 """Handle reception of a new ConnectionStatus."""
197 """Handle the cast added to a group."""
200 """Handle the cast removed from a group."""
205 """Handle reception of a new CastStatus for a group."""
208 """Handle reception of a new MediaStatus for a group."""
213 """Invalidate this status listener.
215 All following callbacks won't be forwarded.
217 if self.
_cast_device_cast_device._cast_info.is_audio_group:
220 self.
_mz_mgr_mz_mgr.deregister_listener(self.
_uuid_uuid, self)
225 """Exception wrapper for pls and m3u helpers."""
228 class PlaylistSupported(PlaylistError):
229 """The playlist is supported by cast devices and should not be parsed."""
242 """Validate the URL can be parsed and at least has scheme + netloc."""
243 result = urlparse(url)
244 return all([result.scheme, result.netloc])
248 """Fetch a playlist from the given url."""
250 session = aiohttp_client.async_get_clientsession(hass, verify_ssl=
False)
251 async
with session.get(url, timeout=aiohttp.ClientTimeout(total=5))
as resp:
252 charset = resp.charset
or "utf-8"
253 if resp.content_type
in supported_content_types:
254 raise PlaylistSupported
256 playlist_data = (await resp.content.read(64 * 1024)).decode(charset)
257 except ValueError
as err:
258 raise PlaylistError(f
"Could not decode playlist {url}")
from err
259 except TimeoutError
as err:
260 raise PlaylistError(f
"Timeout while fetching playlist {url}")
from err
261 except aiohttp.client_exceptions.ClientError
as err:
262 raise PlaylistError(f
"Error while fetching playlist {url}")
from err
268 """Very simple m3u parser.
270 Based on https://github.com/dvndrsn/M3uParser/blob/master/m3uparser.py
273 hls_content_types = (
275 "application/vnd.apple.mpegurl",
280 m3u_lines = m3u_data.splitlines()
287 for line
in m3u_lines:
289 if line.startswith(
"#EXTINF:"):
291 info = line.split(
"#EXTINF:")[1].split(
",", 1)
293 _LOGGER.warning(
"Ignoring invalid extinf %s in playlist %s", line, url)
295 length = info[0].split(
" ", 1)
296 title = info[1].strip()
297 elif line.startswith((
"#EXT-X-VERSION:",
"#EXT-X-STREAM-INF:")):
300 elif line.startswith(
"#"):
306 raise PlaylistError(f
"Invalid item {line} in playlist {url}")
307 playlist.append(
PlaylistItem(length=length, title=title, url=line))
316 """Very simple pls parser.
318 Based on https://github.com/mariob/plsparser/blob/master/src/plsparser.py
322 pls_parser = configparser.ConfigParser()
324 pls_parser.read_string(pls_data, url)
325 except configparser.Error
as err:
329 _PLS_SECTION_PLAYLIST
not in pls_parser
330 or pls_parser[_PLS_SECTION_PLAYLIST].getint(
"Version") != 2
335 num_entries = pls_parser.getint(_PLS_SECTION_PLAYLIST,
"NumberOfEntries")
336 except (configparser.NoOptionError, ValueError)
as err:
337 raise PlaylistError(f
"Invalid NumberOfEntries in playlist {url}")
from err
339 playlist_section = pls_parser[_PLS_SECTION_PLAYLIST]
342 for entry
in range(1, num_entries + 1):
343 file_option = f
"File{entry}"
344 if file_option
not in playlist_section:
345 _LOGGER.warning(
"Missing %s in pls from %s", file_option, url)
347 item_url = playlist_section[file_option]
349 raise PlaylistError(f
"Invalid item {item_url} in playlist {url}")
352 length=playlist_section.get(f
"Length{entry}"),
353 title=playlist_section.get(f
"Title{entry}"),
361 """Parse an m3u or pls playlist."""
362 if url.endswith((
".m3u",
".m3u8")):
def multizone_new_media_status(self, group_uuid, media_status)
def load_media_failed(self, queue_item_id, error_code)
def new_cast_status(self, status)
def added_to_multizone(self, group_uuid)
def new_media_status(self, status)
def removed_from_multizone(self, group_uuid)
def multizone_new_cast_status(self, group_uuid, cast_status)
def new_connection_status(self, status)
def __init__(self, cast_device, chromecast, mz_mgr, mz_only=False)
zeroconf.HaZeroconf|None get_zeroconf(cls)
None set_zeroconf(cls, zeroconf.HaZeroconf zconf)
ChromecastInfo fill_out_missing_chromecast_info(self, HomeAssistant hass)
bool is_audio_group(self)
def parse_playlist(hass, url)
def _fetch_playlist(hass, url, supported_content_types)