1 """Provide functionality to interact with Cast devices on the network."""
3 from __future__
import annotations
5 from collections.abc
import Callable
6 from contextlib
import suppress
7 from datetime
import datetime
8 from functools
import wraps
11 from typing
import TYPE_CHECKING, Any, Concatenate
14 from pychromecast.controllers.homeassistant
import HomeAssistantController
16 MEDIA_PLAYER_ERROR_CODES,
17 MEDIA_PLAYER_STATE_BUFFERING,
18 MEDIA_PLAYER_STATE_PLAYING,
19 MEDIA_PLAYER_STATE_UNKNOWN,
23 from pychromecast.error
import PyChromecastError
24 from pychromecast.quick_play
import quick_play
26 CONNECTION_STATUS_CONNECTED,
27 CONNECTION_STATUS_DISCONNECTED,
37 MediaPlayerDeviceClass,
39 MediaPlayerEntityFeature,
42 async_process_play_media_url,
46 CAST_APP_ID_HOMEASSISTANT_LOVELACE,
48 EVENT_HOMEASSISTANT_STOP,
60 ADDED_CAST_DEVICES_KEY,
61 CAST_MULTIZONE_MANAGER_KEY,
63 DOMAIN
as CAST_DOMAIN,
64 SIGNAL_CAST_DISCOVERED,
66 SIGNAL_HASS_CAST_SHOW_VIEW,
67 HomeAssistantControllerData,
69 from .discovery
import setup_internal_discovery
70 from .helpers
import (
80 from .
import CastProtocol
82 _LOGGER = logging.getLogger(__name__)
84 APP_IDS_UNRELIABLE_MEDIA_INFO = (
"Netflix",)
86 CAST_SPLASH =
"https://www.home-assistant.io/images/cast/splash.png"
88 type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R]
91 def api_error[_CastDeviceT: CastDevice, **_P, _R](
92 func: _FuncType[_CastDeviceT, _P, _R],
93 ) -> _FuncType[_CastDeviceT, _P, _R]:
94 """Handle PyChromecastError and reraise a HomeAssistantError."""
97 def wrapper(self: _CastDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
98 """Wrap a CastDevice method."""
100 return_value = func(self, *args, **kwargs)
101 except PyChromecastError
as err:
103 f
"{self.__class__.__name__}.{func.__name__} Failed: {err}"
113 """Create a CastDevice entity or dynamic group from the chromecast object.
115 Returns None if the cast device has already been added.
117 _LOGGER.debug(
"_async_create_cast_device: %s", info)
118 if info.uuid
is None:
119 _LOGGER.error(
"_async_create_cast_device uuid none: %s", info)
123 added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
124 if info.uuid
in added_casts:
129 added_casts.add(info.uuid)
131 if info.is_dynamic_group:
142 config_entry: ConfigEntry,
143 async_add_entities: AddEntitiesCallback,
145 """Set up Cast from a config entry."""
146 hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
149 pychromecast.IGNORE_CEC += config_entry.data.get(CONF_IGNORE_CEC)
or []
151 wanted_uuids = config_entry.data.get(CONF_UUID)
or None
154 def async_cast_discovered(discover: ChromecastInfo) ->
None:
155 """Handle discovery of a new chromecast."""
158 if wanted_uuids
is not None and str(discover.uuid)
not in wanted_uuids:
163 if cast_device
is not None:
167 ChromeCastZeroconf.set_zeroconf(await zeroconf.async_get_instance(hass))
168 hass.async_add_executor_job(setup_internal_discovery, hass, config_entry)
172 """Representation of a Cast device or dynamic group on the network.
174 This class is the holder of the pychromecast.Chromecast object and its
175 socket client. It therefore handles all reconnects and audio groups changing
176 "elected leader" itself.
181 def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) ->
None:
182 """Initialize the cast device."""
184 self.hass: HomeAssistant = hass
186 self.
_chromecast_chromecast: pychromecast.Chromecast |
None =
None
191 self.
_name_name: str |
None =
None
194 """Create chromecast object."""
202 self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.
_async_stop_async_stop)
205 self.hass.async_create_background_task(
211 """Disconnect chromecast object and remove listeners."""
213 if self.
_cast_info_cast_info.uuid
is not None:
216 self.hass.data[ADDED_CAST_DEVICES_KEY].
remove(self.
_cast_info_cast_info.uuid)
225 """Set up the chromecast object."""
227 "[%s %s] Connecting to cast device by service %s",
232 chromecast = await self.hass.async_add_executor_job(
233 pychromecast.get_chromecast_from_cast_info,
235 ChromeCastZeroconf.get_zeroconf(),
239 if CAST_MULTIZONE_MANAGER_KEY
not in self.hass.data:
240 self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
242 self.
mz_mgrmz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
245 self, chromecast, self.
mz_mgrmz_mgr, self._mz_only
250 """Disconnect Chromecast object if it is set."""
253 "[%s %s] Disconnecting from chromecast socket",
257 await self.hass.async_add_executor_job(self.
_chromecast_chromecast.disconnect)
262 """Invalidate some attributes."""
271 """Handle discovery of new Chromecast."""
272 if self.
_cast_info_cast_info.uuid != discover.uuid:
276 _LOGGER.debug(
"Discovered chromecast with same UUID: %s", discover)
280 """Handle removal of Chromecast."""
283 """Disconnect socket on Home Assistant stop."""
287 """Ensure chromecast is available, to facilitate type checking."""
294 """Representation of a Cast device on the network."""
296 _attr_has_entity_name =
True
298 _attr_should_poll =
False
299 _attr_media_image_remotely_accessible =
True
302 def __init__(self, hass: HomeAssistant, cast_info: ChromecastInfo) ->
None:
303 """Initialize the cast device."""
305 CastDevice.__init__(self, hass, cast_info)
310 self.
mz_media_statusmz_media_status: dict[str, pychromecast.controllers.media.MediaStatus] = {}
318 identifiers={(CAST_DOMAIN,
str(cast_info.uuid).replace(
"-",
""))},
319 manufacturer=
str(cast_info.cast_info.manufacturer),
320 model=cast_info.cast_info.model_name,
321 name=
str(cast_info.friendly_name),
324 if cast_info.cast_info.cast_type
in [
325 pychromecast.const.CAST_TYPE_AUDIO,
326 pychromecast.const.CAST_TYPE_GROUP,
331 """Create chromecast object when added to hass."""
339 """Disconnect Chromecast object when removed."""
347 """Set up the chromecast object."""
356 """Disconnect Chromecast object if it is set."""
363 """Invalidate some attributes."""
375 """Handle updates of the cast status."""
379 cast_status.volume_muted
if self.
cast_statuscast_status
else None
384 """Handle updates of the media status."""
387 and media_status.player_is_idle
388 and media_status.idle_reason ==
"ERROR"
393 with suppress(NoURLAvailableError):
394 external_url =
get_url(self.
hasshass, allow_internal=
False)
396 with suppress(NoURLAvailableError):
397 internal_url =
get_url(self.
hasshass, allow_external=
False)
399 if media_status.content_id:
400 if external_url
and media_status.content_id.startswith(external_url):
401 url_description = f
" from external_url ({external_url})"
402 if internal_url
and media_status.content_id.startswith(internal_url):
403 url_description = f
" from internal_url ({internal_url})"
407 "Failed to cast media %s%s. Please make sure the URL is: "
408 "Reachable from the cast device and either a publicly resolvable "
409 "hostname or an IP address"
411 media_status.content_id,
420 """Handle load media failed."""
422 "[%s %s] Load media failed with code %s(%s) for queue_item_id %s",
426 MEDIA_PLAYER_ERROR_CODES.get(error_code,
"unknown code"),
431 """Handle updates of connection status."""
433 "[%s %s] Received cast device connection status: %s",
436 connection_status.status,
438 if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
444 new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
445 if new_available != self.
availableavailable:
450 "[%s %s] Cast device availability changed: %s",
453 connection_status.status,
456 if new_available
and not self.
_cast_info_cast_info.is_audio_group:
458 for group_uuid
in self.
mz_mgrmz_mgr.get_multizone_memberships(
461 group_media_controller = self.
mz_mgrmz_mgr.get_multizone_mediacontroller(
464 if not group_media_controller:
467 group_uuid, group_media_controller.status
472 """Handle updates of audio group media status."""
474 "[%s %s] Multizone %s media status: %s",
486 """Return media controller.
488 First try from our own cast, then groups which our cast is a member in.
491 media_controller = self.
_chromecast_chromecast.media_controller
495 or media_status.player_state == MEDIA_PLAYER_STATE_UNKNOWN
498 for k, val
in groups.items():
499 if val
and val.player_state != MEDIA_PLAYER_STATE_UNKNOWN:
500 media_controller = self.
mz_mgrmz_mgr.get_multizone_mediacontroller(k)
503 return media_controller
506 def _quick_play(self, app_name: str, data: dict[str, Any]) ->
None:
507 """Launch the app `app_name` and start playing media defined by `data`."""
512 """Quit the currently running app."""
521 """Turn on the cast device."""
524 if not chromecast.is_idle:
528 if chromecast.app_id
is not None:
533 if chromecast.cast_type == pychromecast.const.CAST_TYPE_CHROMECAST:
534 app_data = {
"media_id": CAST_SPLASH,
"media_type":
"image/png"}
535 self.
_quick_play_quick_play(
"default_media_receiver", app_data)
537 self.
_start_app_start_app(pychromecast.config.APP_MEDIA_RECEIVER)
541 """Turn off the cast device."""
546 """Mute the volume."""
551 """Set volume level, range 0..1."""
556 """Send play command."""
558 media_controller.play()
562 """Send pause command."""
564 media_controller.pause()
568 """Send stop command."""
570 media_controller.stop()
574 """Send previous track command."""
576 media_controller.queue_prev()
580 """Send next track command."""
582 media_controller.queue_next()
586 """Seek the media to a specific location."""
588 media_controller.seek(position)
591 """Generate root node."""
594 for platform
in self.
hasshass.data[CAST_DOMAIN][
"cast_platform"].values():
596 await platform.async_get_media_browser_root_object(
603 result = await media_source.async_browse_media(
604 self.
hasshass,
None, content_filter=content_filter
606 children.extend(result.children)
612 if len(children) == 1
and children[0].can_expand:
614 children[0].media_content_type,
615 children[0].media_content_id,
620 media_class=MediaClass.DIRECTORY,
622 media_content_type=
"",
625 children=sorted(children, key=
lambda c: c.title),
630 media_content_type: MediaType | str |
None =
None,
631 media_content_id: str |
None =
None,
633 """Implement the websocket media browsing helper."""
634 content_filter =
None
637 if chromecast.cast_type
in (
638 pychromecast.const.CAST_TYPE_AUDIO,
639 pychromecast.const.CAST_TYPE_GROUP,
642 def audio_content_filter(item):
643 """Filter non audio content."""
644 return item.media_content_type.startswith(
"audio/")
646 content_filter = audio_content_filter
648 if media_content_id
is None:
651 platform: CastProtocol
652 assert media_content_type
is not None
653 for platform
in self.
hasshass.data[CAST_DOMAIN][
"cast_platform"].values():
654 browse_media = await platform.async_browse_media(
658 chromecast.cast_type,
663 return await media_source.async_browse_media(
664 self.
hasshass, media_content_id, content_filter=content_filter
668 self, media_type: MediaType | str, media_id: str, **kwargs: Any
670 """Play a piece of media."""
673 if media_source.is_media_source_id(media_id):
674 sourced_media = await media_source.async_resolve_media(
677 media_type = sourced_media.mime_type
678 media_id = sourced_media.url
680 extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
683 if media_type == CAST_DOMAIN:
685 app_data = json.loads(media_id)
686 if metadata := extra.get(
"metadata"):
687 app_data[
"metadata"] = metadata
688 except json.JSONDecodeError:
689 _LOGGER.error(
"Invalid JSON in media_content_id")
694 if "app_id" in app_data:
695 app_id = app_data.pop(
"app_id")
696 _LOGGER.debug(
"Starting Cast app by ID %s", app_id)
697 await self.
hasshass.async_add_executor_job(self.
_start_app_start_app, app_id)
700 "Extra keys %s were ignored. Please use app_name to cast media",
705 app_name = app_data.pop(
"app_name")
707 await self.
hasshass.async_add_executor_job(
710 except NotImplementedError:
711 _LOGGER.error(
"App %s not supported", app_name)
715 for platform
in self.
hasshass.data[CAST_DOMAIN][
"cast_platform"].values():
716 result = await platform.async_play_media(
717 self.
hasshass, self.
entity_identity_id, chromecast, media_type, media_id
727 parsed = yarl.URL(media_id)
728 if parsed.path.startswith(
"/api/hls/"):
731 "stream_type":
"LIVE",
733 "hlsVideoSegmentFormat":
"fmp4",
736 elif media_id.endswith((
".m3u",
".m3u8",
".pls")):
740 "[%s %s] Playing item %s from playlist %s",
746 media_id = playlist[0].url
747 if title := playlist[0].title:
750 "metadata": {
"title": title},
752 except PlaylistSupported
as err:
754 "[%s %s] Playlist %s is supported: %s",
760 except PlaylistError
as err:
762 "[%s %s] Failed to parse playlist %s: %s",
770 app_data = {
"media_id": media_id,
"media_type": media_type, **extra}
772 "[%s %s] Playing %s with default_media_receiver",
777 await self.
hasshass.async_add_executor_job(
778 self.
_quick_play_quick_play,
"default_media_receiver", app_data
782 """Return media status.
784 First try from our own cast, then groups which our cast is a member in.
791 or media_status.player_state == MEDIA_PLAYER_STATE_UNKNOWN
794 for k, val
in groups.items():
795 if val
and val.player_state != MEDIA_PLAYER_STATE_UNKNOWN:
800 return (media_status, media_status_received)
803 def state(self) -> MediaPlayerState | None:
804 """Return the state of the player."""
807 return MediaPlayerState.PLAYING
808 if (media_status := self.
_media_status_media_status()[0])
is not None:
809 if media_status.player_state == MEDIA_PLAYER_STATE_PLAYING:
810 return MediaPlayerState.PLAYING
811 if media_status.player_state == MEDIA_PLAYER_STATE_BUFFERING:
812 return MediaPlayerState.BUFFERING
813 if media_status.player_is_paused:
814 return MediaPlayerState.PAUSED
815 if media_status.player_is_idle:
816 return MediaPlayerState.IDLE
820 return MediaPlayerState.PLAYING
821 return MediaPlayerState.IDLE
823 return MediaPlayerState.OFF
828 """Content ID of current playing media."""
833 return media_status.content_id
if media_status
else None
837 """Content type of current playing media."""
841 if (media_status := self.
_media_status_media_status()[0])
is None:
843 if media_status.media_is_tvshow:
844 return MediaType.TVSHOW
845 if media_status.media_is_movie:
846 return MediaType.MOVIE
847 if media_status.media_is_musictrack:
848 return MediaType.MUSIC
851 if chromecast.cast_type
in (
852 pychromecast.const.CAST_TYPE_AUDIO,
853 pychromecast.const.CAST_TYPE_GROUP,
855 return MediaType.MUSIC
857 return MediaType.VIDEO
861 """Duration of current playing media in seconds."""
866 return media_status.duration
if media_status
else None
870 """Image url of current playing media."""
871 if (media_status := self.
_media_status_media_status()[0])
is None:
874 images = media_status.images
876 return images[0].url
if images
and images[0].url
else None
880 """Title of current playing media."""
882 return media_status.title
if media_status
else None
886 """Artist of current playing media (Music track only)."""
888 return media_status.artist
if media_status
else None
892 """Album of current playing media (Music track only)."""
894 return media_status.album_name
if media_status
else None
898 """Album artist of current playing media (Music track only)."""
900 return media_status.album_artist
if media_status
else None
904 """Track number of current playing media (Music track only)."""
906 return media_status.track
if media_status
else None
910 """Return the title of the series of current playing media."""
912 return media_status.series_title
if media_status
else None
916 """Season of current playing media (TV Show only)."""
918 return media_status.season
if media_status
else None
922 """Episode of current playing media (TV Show only)."""
924 return media_status.episode
if media_status
else None
928 """Return the ID of the current running app."""
933 """Name of the current running app."""
938 """Flag media player features that are supported."""
940 MediaPlayerEntityFeature.PLAY_MEDIA
941 | MediaPlayerEntityFeature.TURN_OFF
942 | MediaPlayerEntityFeature.TURN_ON
948 and self.
cast_statuscast_status.volume_control_type != VOLUME_CONTROL_TYPE_FIXED
951 MediaPlayerEntityFeature.VOLUME_MUTE
952 | MediaPlayerEntityFeature.VOLUME_SET
955 if media_status
and self.
app_idapp_idapp_idapp_id != CAST_APP_ID_HOMEASSISTANT_LOVELACE:
957 MediaPlayerEntityFeature.PAUSE
958 | MediaPlayerEntityFeature.PLAY
959 | MediaPlayerEntityFeature.STOP
961 if media_status.supports_queue_next:
963 MediaPlayerEntityFeature.PREVIOUS_TRACK
964 | MediaPlayerEntityFeature.NEXT_TRACK
966 if media_status.supports_seek:
967 support |= MediaPlayerEntityFeature.SEEK
969 if "media_source" in self.
hasshass.config.components:
970 support |= MediaPlayerEntityFeature.BROWSE_MEDIA
976 """Position of current playing media in seconds."""
981 if media_status
is None or not (
982 media_status.player_is_playing
983 or media_status.player_is_paused
984 or media_status.player_is_idle
987 return media_status.current_time
991 """When was the position of the current playing media valid.
993 Returns value from homeassistant.util.dt.utcnow().
1001 controller_data: HomeAssistantControllerData,
1004 url_path: str |
None,
1006 """Handle a show view signal."""
1012 def unregister() -> None:
1013 """Handle request to unregister the handler."""
1017 "[%s %s] Unregistering HomeAssistantController",
1025 controller = HomeAssistantController(
1026 **controller_data, unregister=unregister
1029 self.
_chromecast_chromecast.register_handler(controller)
1035 """Representation of a Cast device on the network - for dynamic cast groups."""
1040 """Create chromecast object."""
1044 """Handle removal of Chromecast."""
1045 if self.
_cast_info_cast_info.uuid != discover.uuid:
1049 if not discover.cast_info.services:
1051 _LOGGER.debug(
"Clean up dynamic group: %s", discover)
None async_write_ha_state(self)
None schedule_update_ha_state(self, bool force_refresh=False)
bool remove(self, _T matcher)
def parse_playlist(hass, url)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
str get_url(HomeAssistant hass, *bool require_current_request=False, bool require_ssl=False, bool require_standard_port=False, bool require_cloud=False, bool allow_internal=True, bool allow_external=True, bool allow_cloud=True, bool|None allow_ip=None, bool|None prefer_external=None, bool prefer_cloud=False)
bool is_hass_url(HomeAssistant hass, str url)