Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for interface with an Samsung TV."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 from collections.abc import Sequence
7 from typing import Any
8 
9 from async_upnp_client.aiohttp import AiohttpNotifyServer, AiohttpSessionRequester
10 from async_upnp_client.client import UpnpDevice, UpnpService, UpnpStateVariable
11 from async_upnp_client.client_factory import UpnpFactory
12 from async_upnp_client.exceptions import (
13  UpnpActionResponseError,
14  UpnpCommunicationError,
15  UpnpConnectionError,
16  UpnpError,
17  UpnpResponseError,
18  UpnpXmlContentError,
19 )
20 from async_upnp_client.profiles.dlna import DmrDevice
21 from async_upnp_client.utils import async_get_local_ip
22 import voluptuous as vol
23 
25  MediaPlayerDeviceClass,
26  MediaPlayerEntity,
27  MediaPlayerEntityFeature,
28  MediaPlayerState,
29  MediaType,
30 )
31 from homeassistant.core import HomeAssistant, callback
32 from homeassistant.helpers import config_validation as cv
33 from homeassistant.helpers.aiohttp_client import async_get_clientsession
34 from homeassistant.helpers.entity_platform import AddEntitiesCallback
35 from homeassistant.util.async_ import create_eager_task
36 
37 from . import SamsungTVConfigEntry
38 from .bridge import SamsungTVWSBridge
39 from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, LOGGER
40 from .coordinator import SamsungTVDataUpdateCoordinator
41 from .entity import SamsungTVEntity
42 
43 SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"}
44 
45 SUPPORT_SAMSUNGTV = (
46  MediaPlayerEntityFeature.NEXT_TRACK
47  | MediaPlayerEntityFeature.PAUSE
48  | MediaPlayerEntityFeature.PLAY
49  | MediaPlayerEntityFeature.PLAY_MEDIA
50  | MediaPlayerEntityFeature.PREVIOUS_TRACK
51  | MediaPlayerEntityFeature.SELECT_SOURCE
52  | MediaPlayerEntityFeature.STOP
53  | MediaPlayerEntityFeature.TURN_OFF
54  | MediaPlayerEntityFeature.VOLUME_MUTE
55  | MediaPlayerEntityFeature.VOLUME_SET
56  | MediaPlayerEntityFeature.VOLUME_STEP
57 )
58 
59 
60 # Max delay waiting for app_list to return, as some TVs simply ignore the request
61 APP_LIST_DELAY = 3
62 
63 
65  hass: HomeAssistant,
66  entry: SamsungTVConfigEntry,
67  async_add_entities: AddEntitiesCallback,
68 ) -> None:
69  """Set up the Samsung TV from a config entry."""
70  coordinator = entry.runtime_data
71  async_add_entities([SamsungTVDevice(coordinator)])
72 
73 
75  """Representation of a Samsung TV."""
76 
77  _attr_source_list: list[str]
78  _attr_name = None
79  _attr_device_class = MediaPlayerDeviceClass.TV
80 
81  def __init__(self, coordinator: SamsungTVDataUpdateCoordinator) -> None:
82  """Initialize the Samsung device."""
83  super().__init__(coordinator=coordinator)
84  self._ssdp_rendering_control_location: str | None = (
85  coordinator.config_entry.data.get(CONF_SSDP_RENDERING_CONTROL_LOCATION)
86  )
87  # Assume that the TV is in Play mode
88  self._playing_playing: bool = True
89 
90  self._attr_is_volume_muted_attr_is_volume_muted: bool = False
91  self._attr_source_list_attr_source_list = list(SOURCES)
92  self._app_list_app_list: dict[str, str] | None = None
93  self._app_list_event: asyncio.Event = asyncio.Event()
94 
95  self._attr_supported_features_attr_supported_features = SUPPORT_SAMSUNGTV
96  if self._mac:
97  # (deprecated) add turn-on if mac is available
98  # Triggers have not yet been registered so this is adjusted in the property
99  self._attr_supported_features_attr_supported_features |= MediaPlayerEntityFeature.TURN_ON
100  if self._ssdp_rendering_control_location:
101  self._attr_supported_features_attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET
102 
103  self._bridge_bridge.register_app_list_callback(self._app_list_callback_app_list_callback)
104 
105  self._dmr_device_dmr_device: DmrDevice | None = None
106  self._upnp_server_upnp_server: AiohttpNotifyServer | None = None
107 
108  @property
109  def supported_features(self) -> MediaPlayerEntityFeature:
110  """Flag media player features that are supported."""
111  # `turn_on` triggers are not yet registered during initialisation,
112  # so this property needs to be dynamic
113  if self._turn_on_action_turn_on_action:
114  return self._attr_supported_features_attr_supported_features | MediaPlayerEntityFeature.TURN_ON
115  return self._attr_supported_features_attr_supported_features
116 
117  def _update_sources(self) -> None:
118  self._attr_source_list_attr_source_list = list(SOURCES)
119  if app_list := self._app_list_app_list:
120  self._attr_source_list_attr_source_list.extend(app_list)
121 
122  def _app_list_callback(self, app_list: dict[str, str]) -> None:
123  """App list callback."""
124  self._app_list_app_list = app_list
125  self._update_sources_update_sources()
126  self._app_list_event.set()
127 
128  async def async_added_to_hass(self) -> None:
129  """Run when entity about to be added to hass."""
130  await super().async_added_to_hass()
131  await self._async_extra_update_async_extra_update()
132  self.coordinator.async_extra_update = self._async_extra_update_async_extra_update
133  if self.coordinator.is_on:
134  self._attr_state_attr_state = MediaPlayerState.ON
135  self._update_from_upnp_update_from_upnp()
136  else:
137  self._attr_state_attr_state = MediaPlayerState.OFF
138 
139  async def async_will_remove_from_hass(self) -> None:
140  """Handle removal."""
141  self.coordinator.async_extra_update = None
142  await self._async_shutdown_dmr_async_shutdown_dmr()
143 
144  @callback
145  def _handle_coordinator_update(self) -> None:
146  """Handle data update."""
147  if self.coordinator.is_on:
148  self._attr_state_attr_state = MediaPlayerState.ON
149  self._update_from_upnp_update_from_upnp()
150  else:
151  self._attr_state_attr_state = MediaPlayerState.OFF
152  self.async_write_ha_stateasync_write_ha_state()
153 
154  async def _async_extra_update(self) -> None:
155  """Update state of device."""
156  if not self.coordinator.is_on:
157  if self._dmr_device_dmr_device and self._dmr_device_dmr_device.is_subscribed:
158  await self._dmr_device_dmr_device.async_unsubscribe_services()
159  return
160 
161  startup_tasks: list[asyncio.Task[Any]] = []
162 
163  if not self._app_list_event.is_set():
164  startup_tasks.append(create_eager_task(self._async_startup_app_list_async_startup_app_list()))
165 
166  if self._dmr_device_dmr_device and not self._dmr_device_dmr_device.is_subscribed:
167  startup_tasks.append(create_eager_task(self._async_resubscribe_dmr_async_resubscribe_dmr()))
168  if not self._dmr_device_dmr_device and self._ssdp_rendering_control_location:
169  startup_tasks.append(create_eager_task(self._async_startup_dmr_async_startup_dmr()))
170 
171  if startup_tasks:
172  await asyncio.gather(*startup_tasks)
173 
174  @callback
175  def _update_from_upnp(self) -> bool:
176  # Upnp events can affect other attributes that we currently do not track
177  # We want to avoid checking every attribute in 'async_write_ha_state' as we
178  # currently only care about two attributes
179  if (dmr_device := self._dmr_device_dmr_device) is None:
180  return False
181 
182  has_updates = False
183 
184  if (
185  volume_level := dmr_device.volume_level
186  ) is not None and self._attr_volume_level_attr_volume_level != volume_level:
187  self._attr_volume_level_attr_volume_level = volume_level
188  has_updates = True
189 
190  if (
191  is_muted := dmr_device.is_volume_muted
192  ) is not None and self._attr_is_volume_muted_attr_is_volume_muted != is_muted:
193  self._attr_is_volume_muted_attr_is_volume_muted = is_muted
194  has_updates = True
195 
196  return has_updates
197 
198  async def _async_startup_app_list(self) -> None:
199  await self._bridge_bridge.async_request_app_list()
200  if self._app_list_event.is_set():
201  # The try+wait_for is a bit expensive so we should try not to
202  # enter it unless we have to (Python 3.11 will have zero cost try)
203  return
204  try:
205  async with asyncio.timeout(APP_LIST_DELAY):
206  await self._app_list_event.wait()
207  except TimeoutError as err:
208  # No need to try again
209  self._app_list_event.set()
210  LOGGER.debug("Failed to load app list from %s: %r", self._host, err)
211 
212  async def _async_startup_dmr(self) -> None:
213  assert self._ssdp_rendering_control_location is not None
214  if self._dmr_device_dmr_device is None:
215  session = async_get_clientsession(self.hasshasshass)
216  upnp_requester = AiohttpSessionRequester(session)
217  # Set non_strict to avoid invalid data sent by Samsung TV:
218  # Got invalid value for <UpnpStateVariable(PlaybackStorageMedium, string)>:
219  # NETWORK,NONE
220  upnp_factory = UpnpFactory(upnp_requester, non_strict=True)
221  upnp_device: UpnpDevice | None = None
222  try:
223  upnp_device = await upnp_factory.async_create_device(
224  self._ssdp_rendering_control_location
225  )
226  except (UpnpConnectionError, UpnpResponseError, UpnpXmlContentError) as err:
227  LOGGER.debug("Unable to create Upnp DMR device: %r", err, exc_info=True)
228  return
229  _, event_ip = await async_get_local_ip(
230  self._ssdp_rendering_control_location, self.hasshasshass.loop
231  )
232  source = (event_ip or "0.0.0.0", 0)
233  self._upnp_server_upnp_server = AiohttpNotifyServer(
234  requester=upnp_requester,
235  source=source,
236  callback_url=None,
237  loop=self.hasshasshass.loop,
238  )
239  await self._upnp_server_upnp_server.async_start_server()
240  self._dmr_device_dmr_device = DmrDevice(upnp_device, self._upnp_server_upnp_server.event_handler)
241 
242  try:
243  self._dmr_device_dmr_device.on_event = self._on_upnp_event_on_upnp_event
244  await self._dmr_device_dmr_device.async_subscribe_services(auto_resubscribe=True)
245  except UpnpResponseError as err:
246  # Device rejected subscription request. This is OK, variables
247  # will be polled instead.
248  LOGGER.debug("Device rejected subscription: %r", err)
249  except UpnpError as err:
250  # Don't leave the device half-constructed
251  self._dmr_device_dmr_device.on_event = None
252  self._dmr_device_dmr_device = None
253  await self._upnp_server_upnp_server.async_stop_server()
254  self._upnp_server_upnp_server = None
255  LOGGER.debug("Error while subscribing during device connect: %r", err)
256  raise
257 
258  async def _async_resubscribe_dmr(self) -> None:
259  assert self._dmr_device_dmr_device
260  try:
261  await self._dmr_device_dmr_device.async_subscribe_services(auto_resubscribe=True)
262  except UpnpCommunicationError as err:
263  LOGGER.debug("Device rejected re-subscription: %r", err, exc_info=True)
264 
265  async def _async_shutdown_dmr(self) -> None:
266  """Handle removal."""
267  if (dmr_device := self._dmr_device_dmr_device) is not None:
268  self._dmr_device_dmr_device = None
269  dmr_device.on_event = None
270  await dmr_device.async_unsubscribe_services()
271 
272  if (upnp_server := self._upnp_server_upnp_server) is not None:
273  self._upnp_server_upnp_server = None
274  await upnp_server.async_stop_server()
275 
277  self, service: UpnpService, state_variables: Sequence[UpnpStateVariable]
278  ) -> None:
279  """State variable(s) changed, let home-assistant know."""
280  # Ensure the entity has been added to hass to avoid race condition
281  if self._update_from_upnp_update_from_upnp() and self.entity_identity_id:
282  self.async_write_ha_stateasync_write_ha_state()
283 
284  async def _async_launch_app(self, app_id: str) -> None:
285  """Send launch_app to the tv."""
286  if self._bridge_bridge.power_off_in_progress:
287  LOGGER.debug("TV is powering off, not sending launch_app command")
288  return
289  assert isinstance(self._bridge_bridge, SamsungTVWSBridge)
290  await self._bridge_bridge.async_launch_app(app_id)
291 
292  async def _async_send_keys(self, keys: list[str]) -> None:
293  """Send a key to the tv and handles exceptions."""
294  assert keys
295  if self._bridge_bridge.power_off_in_progress and keys[0] != "KEY_POWEROFF":
296  LOGGER.debug("TV is powering off, not sending keys: %s", keys)
297  return
298  await self._bridge_bridge.async_send_keys(keys)
299 
300  async def async_turn_off(self) -> None:
301  """Turn off media player."""
302  await super()._async_turn_off()
303 
304  async def async_set_volume_level(self, volume: float) -> None:
305  """Set volume level on the media player."""
306  if (dmr_device := self._dmr_device_dmr_device) is None:
307  LOGGER.warning("Upnp services are not available on %s", self._host)
308  return
309  try:
310  await dmr_device.async_set_volume_level(volume)
311  except UpnpActionResponseError as err:
312  LOGGER.warning("Unable to set volume level on %s: %r", self._host, err)
313 
314  async def async_volume_up(self) -> None:
315  """Volume up the media player."""
316  await self._async_send_keys_async_send_keys(["KEY_VOLUP"])
317 
318  async def async_volume_down(self) -> None:
319  """Volume down media player."""
320  await self._async_send_keys_async_send_keys(["KEY_VOLDOWN"])
321 
322  async def async_mute_volume(self, mute: bool) -> None:
323  """Send mute command."""
324  await self._async_send_keys_async_send_keys(["KEY_MUTE"])
325 
326  async def async_media_play_pause(self) -> None:
327  """Simulate play pause media player."""
328  if self._playing_playing:
329  await self.async_media_pauseasync_media_pauseasync_media_pause()
330  else:
331  await self.async_media_playasync_media_playasync_media_play()
332 
333  async def async_media_play(self) -> None:
334  """Send play command."""
335  self._playing_playing = True
336  await self._async_send_keys_async_send_keys(["KEY_PLAY"])
337 
338  async def async_media_pause(self) -> None:
339  """Send media pause command to media player."""
340  self._playing_playing = False
341  await self._async_send_keys_async_send_keys(["KEY_PAUSE"])
342 
343  async def async_media_next_track(self) -> None:
344  """Send next track command."""
345  await self._async_send_keys_async_send_keys(["KEY_CHUP"])
346 
347  async def async_media_previous_track(self) -> None:
348  """Send the previous track command."""
349  await self._async_send_keys_async_send_keys(["KEY_CHDOWN"])
350 
351  async def async_play_media(
352  self, media_type: MediaType | str, media_id: str, **kwargs: Any
353  ) -> None:
354  """Support changing a channel."""
355  if media_type == MediaType.APP:
356  await self._async_launch_app_async_launch_app(media_id)
357  return
358 
359  if media_type != MediaType.CHANNEL:
360  LOGGER.error("Unsupported media type")
361  return
362 
363  # media_id should only be a channel number
364  try:
365  cv.positive_int(media_id)
366  except vol.Invalid:
367  LOGGER.error("Media ID must be positive integer")
368  return
369 
370  await self._async_send_keys_async_send_keys(
371  keys=[f"KEY_{digit}" for digit in media_id] + ["KEY_ENTER"]
372  )
373 
374  async def async_turn_on(self) -> None:
375  """Turn the media player on."""
376  await super()._async_turn_on()
377 
378  async def async_select_source(self, source: str) -> None:
379  """Select input source."""
380  if self._app_list_app_list and source in self._app_list_app_list:
381  await self._async_launch_app_async_launch_app(self._app_list_app_list[source])
382  return
383 
384  if source in SOURCES:
385  await self._async_send_keys_async_send_keys([SOURCES[source]])
386  return
387 
388  LOGGER.error("Unsupported source")
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None _on_upnp_event(self, UpnpService service, Sequence[UpnpStateVariable] state_variables)
None __init__(self, SamsungTVDataUpdateCoordinator coordinator)
Definition: media_player.py:81
None async_setup_entry(HomeAssistant hass, SamsungTVConfigEntry entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:68
aiohttp.ClientSession async_get_clientsession(HomeAssistant hass, bool verify_ssl=True, socket.AddressFamily family=socket.AF_UNSPEC, ssl_util.SSLCipherList ssl_cipher=ssl_util.SSLCipherList.PYTHON_DEFAULT)