1 """Support for DLNA DMR (Device Media Renderer)."""
3 from __future__
import annotations
6 from collections.abc
import Awaitable, Callable, Coroutine, Sequence
8 from datetime
import datetime, timedelta
10 from typing
import Any, Concatenate
12 from async_upnp_client.client
import UpnpService, UpnpStateVariable
13 from async_upnp_client.const
import NotificationSubType
14 from async_upnp_client.exceptions
import UpnpError, UpnpResponseError
15 from async_upnp_client.profiles.dlna
import DmrDevice, PlayMode, TransportState
16 from async_upnp_client.utils
import async_get_local_ip
17 from didl_lite
import didl_lite
19 from homeassistant
import config_entries
23 DOMAIN
as MEDIA_PLAYER_DOMAIN,
26 MediaPlayerEntityFeature,
30 async_process_play_media_url,
38 CONF_BROWSE_UNFILTERED,
39 CONF_CALLBACK_URL_OVERRIDE,
41 CONF_POLL_AVAILABILITY,
51 from .data
import EventListenAddr, get_domain_data
55 _TRANSPORT_STATE_TO_MEDIA_PLAYER_STATE = {
56 TransportState.PLAYING: MediaPlayerState.PLAYING,
57 TransportState.TRANSITIONING: MediaPlayerState.PLAYING,
58 TransportState.PAUSED_PLAYBACK: MediaPlayerState.PAUSED,
59 TransportState.PAUSED_RECORDING: MediaPlayerState.PAUSED,
61 TransportState.VENDOR_DEFINED:
None,
62 None: MediaPlayerState.ON,
66 def catch_request_errors[_DlnaDmrEntityT: DlnaDmrEntity, **_P, _R](
67 func: Callable[Concatenate[_DlnaDmrEntityT, _P], Awaitable[_R]],
68 ) -> Callable[Concatenate[_DlnaDmrEntityT, _P], Coroutine[Any, Any, _R |
None]]:
69 """Catch UpnpError errors."""
71 @functools.wraps(func)
73 self: _DlnaDmrEntityT, *args: _P.args, **kwargs: _P.kwargs
75 """Catch UpnpError errors and check availability before and after request."""
76 if not self.available:
78 "Device disappeared when trying to call service %s", func.__name__
82 return await func(self, *args, **kwargs)
83 except UpnpError
as err:
84 self.check_available =
True
85 _LOGGER.error(
"Error during call %s: %r", func.__name__, err)
94 async_add_entities: AddEntitiesCallback,
96 """Set up the DlnaDmrEntity from a config entry."""
97 _LOGGER.debug(
"media_player.async_setup_entry %s (%s)", entry.entry_id, entry.title)
99 udn = entry.data[CONF_DEVICE_ID]
100 ent_reg = er.async_get(hass)
101 dev_reg = dr.async_get(hass)
105 existing_entity_id := ent_reg.async_get_entity_id(
106 domain=MEDIA_PLAYER_DOMAIN, platform=DOMAIN, unique_id=udn
109 and (existing_entry := ent_reg.async_get(existing_entity_id))
110 and (device_id := existing_entry.device_id)
111 and (device_entry := dev_reg.async_get(device_id))
112 and (dr.CONNECTION_UPNP, udn)
not in device_entry.connections
117 dev_reg.async_update_device(
119 merge_connections={(dr.CONNECTION_UPNP, udn)},
123 entity = DlnaDmrEntity(
125 device_type=entry.data[CONF_TYPE],
127 event_port=entry.options.get(CONF_LISTEN_PORT)
or 0,
128 event_callback_url=entry.options.get(CONF_CALLBACK_URL_OVERRIDE),
129 poll_availability=entry.options.get(CONF_POLL_AVAILABILITY,
False),
130 location=entry.data[CONF_URL],
131 mac_address=entry.data.get(CONF_MAC),
132 browse_unfiltered=entry.options.get(CONF_BROWSE_UNFILTERED,
False),
140 """Representation of a DLNA DMR device as a HA entity."""
145 _event_addr: EventListenAddr
146 poll_availability: bool
151 browse_unfiltered: bool
153 _device_lock: asyncio.Lock
154 _device: DmrDevice |
None =
None
155 check_available: bool =
False
156 _ssdp_connect_failed: bool =
False
159 _bootid: int |
None =
None
163 _attr_should_poll =
True
166 _attr_sound_mode =
None
174 event_callback_url: str |
None,
175 poll_availability: bool,
177 mac_address: str |
None,
178 browse_unfiltered: bool,
181 """Initialize DLNA DMR entity."""
183 self.device_type = device_type
184 self._attr_name = name
185 self._event_addr =
EventListenAddr(
None, event_port, event_callback_url)
186 self.poll_availability = poll_availability
187 self.location = location
188 self.mac_address = mac_address
189 self.browse_unfiltered = browse_unfiltered
190 self._device_lock = asyncio.Lock()
191 self._background_setup_task: asyncio.Task[
None] |
None =
None
192 self._updated_registry: bool =
False
193 self._config_entry = config_entry
194 self._attr_device_info = dr.DeviceInfo(connections={(dr.CONNECTION_UPNP, udn)})
195 self._attr_supported_features = self._supported_features()
198 """Handle addition."""
200 self.async_on_remove(
201 self._config_entry.add_update_listener(self.async_config_update_listener)
205 self.async_on_remove(
206 await ssdp.async_register_callback(
207 self.hass, self.async_ssdp_callback, {
"USN": self.usn}
215 self.async_on_remove(
216 await ssdp.async_register_callback(
218 self.async_ssdp_callback,
219 {
"_udn": self.udn,
"NTS": NotificationSubType.SSDP_BYEBYE},
224 if self.hass.state
is CoreState.running:
225 await self._async_setup()
227 self._background_setup_task = self.hass.async_create_background_task(
228 self._async_setup(), f
"dlna_dmr {self.name} setup"
231 async
def _async_setup(self) -> None:
234 await self._device_connect(self.location)
235 except UpnpError
as err:
236 _LOGGER.debug(
"Couldn't connect immediately: %r", err)
239 """Handle removal."""
240 if self._background_setup_task:
241 self._background_setup_task.cancel()
242 with contextlib.suppress(asyncio.CancelledError):
243 await self._background_setup_task
244 self._background_setup_task =
None
246 await self._device_disconnect()
248 async
def async_ssdp_callback(
249 self, info: ssdp.SsdpServiceInfo, change: ssdp.SsdpChange
251 """Handle notification from SSDP of device state change."""
253 "SSDP %s notification of device %s at %s",
260 bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_BOOTID]
261 bootid: int |
None =
int(bootid_str, 10)
262 except (KeyError, ValueError):
265 if change == ssdp.SsdpChange.UPDATE:
267 if self._bootid
is not None and self._bootid == bootid:
270 with contextlib.suppress(KeyError, ValueError):
271 next_bootid_str = info.ssdp_headers[ssdp.ATTR_SSDP_NEXTBOOTID]
272 self._bootid =
int(next_bootid_str, 10)
276 if self._bootid
is not None and self._bootid != bootid:
279 self._ssdp_connect_failed =
False
282 await self._device_disconnect()
283 self._bootid = bootid
285 if change == ssdp.SsdpChange.BYEBYE:
289 await self._device_disconnect()
291 self._ssdp_connect_failed =
False
294 change == ssdp.SsdpChange.ALIVE
296 and not self._ssdp_connect_failed
298 assert info.ssdp_location
299 location = info.ssdp_location
301 await self._device_connect(location)
302 except UpnpError
as err:
303 self._ssdp_connect_failed =
True
305 "Failed connecting to recently alive device at %s: %r",
311 self.async_write_ha_state()
313 async
def async_config_update_listener(
316 """Handle options update by modifying self in-place."""
318 "Updating: %s with data=%s and options=%s",
323 self.location = entry.data[CONF_URL]
324 self.poll_availability = entry.options.get(CONF_POLL_AVAILABILITY,
False)
325 self.browse_unfiltered = entry.options.get(CONF_BROWSE_UNFILTERED,
False)
327 new_mac_address = entry.data.get(CONF_MAC)
328 if new_mac_address != self.mac_address:
329 self.mac_address = new_mac_address
330 self._update_device_registry(set_mac=
True)
332 new_port = entry.options.get(CONF_LISTEN_PORT)
or 0
333 new_callback_url = entry.options.get(CONF_CALLBACK_URL_OVERRIDE)
336 new_port == self._event_addr.port
337 and new_callback_url == self._event_addr.callback_url
342 await self._device_disconnect()
344 self._event_addr = self._event_addr._replace(
345 port=new_port, callback_url=new_callback_url
348 await self._device_connect(self.location)
349 except UpnpError
as err:
350 _LOGGER.warning(
"Couldn't (re)connect after config change: %r", err)
353 self.async_write_ha_state()
355 def async_write_ha_state(self) -> None:
356 """Write the state."""
357 self._attr_supported_features = self._supported_features()
358 super().async_write_ha_state()
360 async
def _device_connect(self, location: str) ->
None:
361 """Connect to the device now that it's available."""
362 _LOGGER.debug(
"Connecting to device at %s", location)
364 async
with self._device_lock:
366 _LOGGER.debug(
"Trying to connect when device already connected")
372 upnp_device = await domain_data.upnp_factory.async_create_device(location)
376 _, event_ip = await async_get_local_ip(location, self.hass.loop)
377 self._event_addr = self._event_addr._replace(host=event_ip)
378 event_handler = await domain_data.async_get_event_notifier(
379 self._event_addr, self.hass
383 self._device = DmrDevice(upnp_device, event_handler)
385 self.location = location
389 self._device.on_event = self._on_event
390 await self._device.async_subscribe_services(auto_resubscribe=
True)
391 except UpnpResponseError
as err:
394 _LOGGER.debug(
"Device rejected subscription: %r", err)
395 except UpnpError
as err:
397 self._device.on_event =
None
399 await domain_data.async_release_event_notifier(self._event_addr)
400 _LOGGER.debug(
"Error while subscribing during device connect: %r", err)
403 self._update_device_registry()
405 def _update_device_registry(self, set_mac: bool =
False) ->
None:
406 """Update the device registry with new information about the DMR."""
412 (
not set_mac
and self._updated_registry)
421 self._device.profile_device.root_device.udn,
423 (dr.CONNECTION_UPNP, self._device.udn),
434 (dr.CONNECTION_NETWORK_MAC, self.mac_address)
437 device_info = dr.DeviceInfo(
438 connections=connections,
439 default_manufacturer=self._device.manufacturer,
440 default_model=self._device.model_name,
441 default_name=self._device.name,
443 self._attr_device_info = device_info
445 self._updated_registry =
True
447 device_entry = dr.async_get(self.hass).async_get_or_create(
448 config_entry_id=self._config_entry.entry_id, **device_info
452 er.async_get(self.hass).async_get_or_create(
456 device_id=device_entry.id,
457 config_entry=self._config_entry,
460 async
def _device_disconnect(self) -> None:
461 """Destroy connections to the device now that it's not available.
463 Also call when removing this entity from hass to clean up connections.
465 async
with self._device_lock:
467 _LOGGER.debug(
"Disconnecting from device that's not connected")
470 _LOGGER.debug(
"Disconnecting from %s", self._device.name)
472 self._device.on_event =
None
473 old_device = self._device
475 await old_device.async_unsubscribe_services()
478 await domain_data.async_release_event_notifier(self._event_addr)
481 """Retrieve the latest data."""
482 if self._background_setup_task:
483 await self._background_setup_task
484 self._background_setup_task =
None
487 if not self.poll_availability:
490 await self._device_connect(self.location)
494 assert self._device
is not None
497 do_ping = self.poll_availability
or self.check_available
499 except UpnpError
as err:
500 _LOGGER.debug(
"Device unavailable: %r", err)
501 await self._device_disconnect()
504 self.check_available =
False
507 self._attr_supported_features = self._supported_features()
510 self, service: UpnpService, state_variables: Sequence[UpnpStateVariable]
512 """State variable(s) changed, let home-assistant know."""
513 if not state_variables:
515 self.check_available =
True
517 force_refresh =
False
519 if service.service_id ==
"urn:upnp-org:serviceId:AVTransport":
520 for state_variable
in state_variables:
523 if state_variable.name ==
"TransportState" and state_variable.value
in (
524 TransportState.PLAYING,
525 TransportState.PAUSED_PLAYBACK,
531 self.async_schedule_update_ha_state(force_refresh)
533 self.async_write_ha_state()
537 """Device is available when we have a connection to it."""
538 return self._device
is not None and self._device.profile_device.available
542 """Report the UDN (Unique Device Name) as this entity's unique ID."""
546 def usn(self) -> str:
547 """Get the USN based on the UDN (Unique Device Name) and device type."""
548 return f
"{self.udn}::{self.device_type}"
551 def state(self) -> MediaPlayerState | None:
552 """State of the player."""
554 return MediaPlayerState.OFF
555 return _TRANSPORT_STATE_TO_MEDIA_PLAYER_STATE.get(
556 self._device.transport_state, MediaPlayerState.IDLE
559 def _supported_features(self) -> MediaPlayerEntityFeature:
560 """Flag media player features that are supported at this moment.
562 Supported features may change as the device enters different states.
569 if self._device.has_volume_level:
570 supported_features |= MediaPlayerEntityFeature.VOLUME_SET
571 if self._device.has_volume_mute:
572 supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE
573 if self._device.can_play:
574 supported_features |= MediaPlayerEntityFeature.PLAY
575 if self._device.can_pause:
576 supported_features |= MediaPlayerEntityFeature.PAUSE
577 if self._device.can_stop:
578 supported_features |= MediaPlayerEntityFeature.STOP
579 if self._device.can_previous:
580 supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK
581 if self._device.can_next:
582 supported_features |= MediaPlayerEntityFeature.NEXT_TRACK
583 if self._device.has_play_media:
584 supported_features |= (
585 MediaPlayerEntityFeature.PLAY_MEDIA
586 | MediaPlayerEntityFeature.BROWSE_MEDIA
588 if self._device.can_seek_rel_time:
589 supported_features |= MediaPlayerEntityFeature.SEEK
591 play_modes = self._device.valid_play_modes
592 if play_modes & {PlayMode.RANDOM, PlayMode.SHUFFLE}:
593 supported_features |= MediaPlayerEntityFeature.SHUFFLE_SET
594 if play_modes & {PlayMode.REPEAT_ONE, PlayMode.REPEAT_ALL}:
595 supported_features |= MediaPlayerEntityFeature.REPEAT_SET
597 if self._device.has_presets:
598 supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
600 return supported_features
603 def volume_level(self) -> float | None:
604 """Volume level of the media player (0..1)."""
605 if not self._device
or not self._device.has_volume_level:
607 return self._device.volume_level
609 @catch_request_errors
610 async
def async_set_volume_level(self, volume: float) ->
None:
611 """Set volume level, range 0..1."""
612 assert self._device
is not None
613 await self._device.async_set_volume_level(volume)
616 def is_volume_muted(self) -> bool | None:
617 """Boolean if volume is currently muted."""
620 return self._device.is_volume_muted
622 @catch_request_errors
623 async
def async_mute_volume(self, mute: bool) ->
None:
624 """Mute the volume."""
625 assert self._device
is not None
626 desired_mute = bool(mute)
627 await self._device.async_mute_volume(desired_mute)
629 @catch_request_errors
630 async
def async_media_pause(self) -> None:
631 """Send pause command."""
632 assert self._device
is not None
633 await self._device.async_pause()
635 @catch_request_errors
636 async
def async_media_play(self) -> None:
637 """Send play command."""
638 assert self._device
is not None
639 await self._device.async_play()
641 @catch_request_errors
642 async
def async_media_stop(self) -> None:
643 """Send stop command."""
644 assert self._device
is not None
647 @catch_request_errors
648 async
def async_media_seek(self, position: float) ->
None:
649 """Send seek command."""
650 assert self._device
is not None
652 await self._device.async_seek_rel_time(time)
654 @catch_request_errors
656 self, media_type: MediaType | str, media_id: str, **kwargs: Any
658 """Play a piece of media."""
659 _LOGGER.debug(
"Playing media: %s, %s, %s", media_type, media_id, kwargs)
660 assert self._device
is not None
662 didl_metadata: str |
None =
None
666 if media_source.is_media_source_id(media_id):
667 sourced_media = await media_source.async_resolve_media(
668 self.hass, media_id, self.entity_id
670 media_type = sourced_media.mime_type
671 media_id = sourced_media.url
672 _LOGGER.debug(
"sourced_media is %s", sourced_media)
673 if sourced_metadata := getattr(sourced_media,
"didl_metadata",
None):
674 didl_metadata = didl_lite.to_xml_string(sourced_metadata).decode(
677 title = sourced_metadata.title
682 extra: dict[str, Any] = kwargs.get(ATTR_MEDIA_EXTRA)
or {}
683 metadata: dict[str, Any] = extra.get(
"metadata")
or {}
686 title = extra.get(
"title")
or metadata.get(
"title")
or "Home Assistant"
687 if thumb := extra.get(
"thumb"):
688 metadata[
"album_art_uri"] = thumb
691 for hass_key, didl_key
in MEDIA_METADATA_DIDL.items():
692 if hass_key
in metadata:
693 metadata[didl_key] = metadata.pop(hass_key)
695 if not didl_metadata:
698 upnp_class = MEDIA_UPNP_CLASS_MAP.get(media_type)
699 didl_metadata = await self._device.construct_play_media_metadata(
702 override_upnp_class=upnp_class,
707 if self._device.can_stop:
708 await self.async_media_stop()
711 await self._device.async_set_transport_uri(media_id, title, didl_metadata)
714 autoplay = extra.get(
"autoplay",
True)
715 if self._device.transport_state == TransportState.PLAYING
or not autoplay:
719 await self._device.async_wait_for_can_play()
720 await self.async_media_play()
722 @catch_request_errors
723 async
def async_media_previous_track(self) -> None:
724 """Send previous track command."""
725 assert self._device
is not None
726 await self._device.async_previous()
728 @catch_request_errors
729 async
def async_media_next_track(self) -> None:
730 """Send next track command."""
731 assert self._device
is not None
732 await self._device.async_next()
735 def shuffle(self) -> bool | None:
736 """Boolean if shuffle is enabled."""
740 if not (play_mode := self._device.play_mode):
743 if play_mode == PlayMode.VENDOR_DEFINED:
746 return play_mode
in (PlayMode.SHUFFLE, PlayMode.RANDOM)
748 @catch_request_errors
749 async
def async_set_shuffle(self, shuffle: bool) ->
None:
750 """Enable/disable shuffle mode."""
751 assert self._device
is not None
753 repeat = self.repeat
or RepeatMode.OFF
754 potential_play_modes = SHUFFLE_PLAY_MODES[(shuffle, repeat)]
756 valid_play_modes = self._device.valid_play_modes
758 for mode
in potential_play_modes:
759 if mode
in valid_play_modes:
760 await self._device.async_set_play_mode(mode)
764 "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat
768 def repeat(self) -> RepeatMode | None:
769 """Return current repeat mode."""
773 if not (play_mode := self._device.play_mode):
776 if play_mode == PlayMode.VENDOR_DEFINED:
779 if play_mode == PlayMode.REPEAT_ONE:
780 return RepeatMode.ONE
782 if play_mode
in (PlayMode.REPEAT_ALL, PlayMode.RANDOM):
783 return RepeatMode.ALL
785 return RepeatMode.OFF
787 @catch_request_errors
788 async
def async_set_repeat(self, repeat: RepeatMode) ->
None:
789 """Set repeat mode."""
790 assert self._device
is not None
792 shuffle = self.shuffle
or False
793 potential_play_modes = REPEAT_PLAY_MODES[(shuffle, repeat)]
795 valid_play_modes = self._device.valid_play_modes
797 for mode
in potential_play_modes:
798 if mode
in valid_play_modes:
799 await self._device.async_set_play_mode(mode)
803 "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat
807 def sound_mode_list(self) -> list[str] | None:
808 """List of available sound modes."""
811 return self._device.preset_names
813 @catch_request_errors
814 async
def async_select_sound_mode(self, sound_mode: str) ->
None:
815 """Select sound mode."""
816 assert self._device
is not None
817 await self._device.async_select_preset(sound_mode)
821 media_content_type: MediaType | str |
None =
None,
822 media_content_id: str |
None =
None,
824 """Implement the websocket media browsing helper.
826 Browses all available media_sources by default. Filters content_type
827 based on the DMR's sink_protocol_info.
830 "async_browse_media(%s, %s)", media_content_type, media_content_id
836 if self.browse_unfiltered:
837 content_filter =
None
839 content_filter = self._get_content_filter()
841 return await media_source.async_browse_media(
842 self.hass, media_content_id, content_filter=content_filter
845 def _get_content_filter(self) -> Callable[[BrowseMedia], bool]:
846 """Return a function that filters media based on what the renderer can play.
848 The filtering is pretty loose; it's better to show something that can't
849 be played than hide something that can.
851 if not self._device
or not self._device.sink_protocol_info:
853 _LOGGER.debug(
"Get content filter with no device or sink protocol info")
854 return lambda _:
True
856 _LOGGER.debug(
"Get content filter for %s", self._device.sink_protocol_info)
857 if self._device.sink_protocol_info[0] ==
"*":
859 return lambda _:
True
863 content_types = set[str]()
864 for protocol_info
in self._device.sink_protocol_info:
865 protocol, _, content_format, _ = protocol_info.split(
":", 3)
867 content_format = content_format.lower().replace(
"/x-",
"/", 1)
868 content_format = content_format.partition(
";")[0]
870 if protocol
in STREAMABLE_PROTOCOLS:
871 content_types.add(content_format)
873 def _content_filter(item: BrowseMedia) -> bool:
874 """Filter media items by their media_content_type."""
875 content_type = item.media_content_type
876 content_type = content_type.lower().replace(
"/x-",
"/", 1).partition(
";")[0]
877 return content_type
in content_types
879 return _content_filter
882 def media_title(self) -> str | None:
883 """Title of current playing media."""
887 return self._device.media_program_title
or self._device.media_title
890 def media_image_url(self) -> str | None:
891 """Image url of current playing media."""
894 return self._device.media_image_url
897 def media_content_id(self) -> str | None:
898 """Content ID of current playing media."""
901 return self._device.current_track_uri
904 def media_content_type(self) -> MediaType | None:
905 """Content type of current playing media."""
906 if not self._device
or not self._device.media_class:
908 return MEDIA_TYPE_MAP.get(self._device.media_class)
911 def media_duration(self) -> int | None:
912 """Duration of current playing media in seconds."""
915 return self._device.media_duration
918 def media_position(self) -> int | None:
919 """Position of current playing media in seconds."""
922 return self._device.media_position
925 def media_position_updated_at(self) -> datetime | None:
926 """When was the position of the current playing media valid.
928 Returns value from homeassistant.util.dt.utcnow().
932 return self._device.media_position_updated_at
935 def media_artist(self) -> str | None:
936 """Artist of current playing media, music track only."""
939 return self._device.media_artist
942 def media_album_name(self) -> str | None:
943 """Album name of current playing media, music track only."""
946 return self._device.media_album_name
949 def media_album_artist(self) -> str | None:
950 """Album artist of current playing media, music track only."""
953 return self._device.media_album_artist
956 def media_track(self) -> int | None:
957 """Track number of current playing media, music track only."""
960 return self._device.media_track_number
963 def media_series_title(self) -> str | None:
964 """Title of series of current playing media, TV show only."""
967 return self._device.media_series_title
970 def media_season(self) -> str | None:
971 """Season number, starting at 1, of current playing media, TV show only."""
977 not self._device.media_season_number
978 or self._device.media_season_number ==
"0"
979 )
and self._device.media_episode_number:
980 with contextlib.suppress(ValueError):
981 episode =
int(self._device.media_episode_number, 10)
983 return str(episode // 100)
984 return self._device.media_season_number
987 def media_episode(self) -> str | None:
988 """Episode number of current playing media, TV show only."""
993 not self._device.media_season_number
994 or self._device.media_season_number ==
"0"
995 )
and self._device.media_episode_number:
996 with contextlib.suppress(ValueError):
997 episode =
int(self._device.media_episode_number, 10)
999 return str(episode % 100)
1000 return self._device.media_episode_number
1003 def media_channel(self) -> str | None:
1004 """Channel name currently playing."""
1005 if not self._device:
1007 return self._device.media_channel_name
1010 def media_playlist(self) -> str | None:
1011 """Title of Playlist currently playing."""
1012 if not self._device:
1014 return self._device.media_playlist_title
None __init__(self, _AOSmithCoordinatorT coordinator, str junction_id)
None async_added_to_hass(self)
None async_will_remove_from_hass(self)
DlnaDmrData get_domain_data(HomeAssistant hass)
bool async_setup_entry(HomeAssistant hass, config_entries.ConfigEntry entry)
BrowseMedia|None async_browse_media(HomeAssistant hass, MediaType|str media_content_type, str media_content_id, str cast_type)
bool async_play_media(HomeAssistant hass, str cast_entity_id, Chromecast chromecast, MediaType|str media_type, str media_id)
None async_stop(HomeAssistant hass)
bool state(HomeAssistant hass, str|State|None entity, Any req_state, timedelta|None for_period=None, str|None attribute=None, TemplateVarsType variables=None)