Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Vizio SmartCast Device support."""
2 
3 from __future__ import annotations
4 
5 from datetime import timedelta
6 import logging
7 
8 from pyvizio import AppConfig, VizioAsync
9 from pyvizio.api.apps import find_app_name
10 from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
11 
13  MediaPlayerDeviceClass,
14  MediaPlayerEntity,
15  MediaPlayerEntityFeature,
16  MediaPlayerState,
17 )
18 from homeassistant.config_entries import ConfigEntry
19 from homeassistant.const import (
20  CONF_ACCESS_TOKEN,
21  CONF_DEVICE_CLASS,
22  CONF_EXCLUDE,
23  CONF_HOST,
24  CONF_INCLUDE,
25  CONF_NAME,
26 )
27 from homeassistant.core import HomeAssistant, callback
28 from homeassistant.helpers import device_registry as dr, entity_platform
29 from homeassistant.helpers.aiohttp_client import async_get_clientsession
30 from homeassistant.helpers.device_registry import DeviceInfo
32  async_dispatcher_connect,
33  async_dispatcher_send,
34 )
35 from homeassistant.helpers.entity_platform import AddEntitiesCallback
36 
37 from .const import (
38  CONF_ADDITIONAL_CONFIGS,
39  CONF_APPS,
40  CONF_VOLUME_STEP,
41  DEFAULT_TIMEOUT,
42  DEFAULT_VOLUME_STEP,
43  DEVICE_ID,
44  DOMAIN,
45  SERVICE_UPDATE_SETTING,
46  SUPPORTED_COMMANDS,
47  UPDATE_SETTING_SCHEMA,
48  VIZIO_AUDIO_SETTINGS,
49  VIZIO_DEVICE_CLASSES,
50  VIZIO_MUTE,
51  VIZIO_MUTE_ON,
52  VIZIO_SOUND_MODE,
53  VIZIO_VOLUME,
54 )
55 from .coordinator import VizioAppsDataUpdateCoordinator
56 
57 _LOGGER = logging.getLogger(__name__)
58 
59 SCAN_INTERVAL = timedelta(seconds=30)
60 PARALLEL_UPDATES = 0
61 
62 
64  hass: HomeAssistant,
65  config_entry: ConfigEntry,
66  async_add_entities: AddEntitiesCallback,
67 ) -> None:
68  """Set up a Vizio media player entry."""
69  host = config_entry.data[CONF_HOST]
70  token = config_entry.data.get(CONF_ACCESS_TOKEN)
71  name = config_entry.data[CONF_NAME]
72  device_class = config_entry.data[CONF_DEVICE_CLASS]
73 
74  # If config entry options not set up, set them up,
75  # otherwise assign values managed in options
76  volume_step = config_entry.options.get(
77  CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP)
78  )
79 
80  params = {}
81  if not config_entry.options:
82  params["options"] = {CONF_VOLUME_STEP: volume_step}
83 
84  include_or_exclude_key = next(
85  (
86  key
87  for key in config_entry.data.get(CONF_APPS, {})
88  if key in (CONF_INCLUDE, CONF_EXCLUDE)
89  ),
90  None,
91  )
92  if include_or_exclude_key:
93  params["options"][CONF_APPS] = {
94  include_or_exclude_key: config_entry.data[CONF_APPS][
95  include_or_exclude_key
96  ].copy()
97  }
98 
99  if not config_entry.data.get(CONF_VOLUME_STEP):
100  new_data = config_entry.data.copy()
101  new_data.update({CONF_VOLUME_STEP: volume_step})
102  params["data"] = new_data
103 
104  if params:
105  hass.config_entries.async_update_entry(
106  config_entry,
107  **params, # type: ignore[arg-type]
108  )
109 
110  device = VizioAsync(
111  DEVICE_ID,
112  host,
113  name,
114  auth_token=token,
115  device_type=VIZIO_DEVICE_CLASSES[device_class],
116  session=async_get_clientsession(hass, False),
117  timeout=DEFAULT_TIMEOUT,
118  )
119 
120  apps_coordinator = hass.data[DOMAIN].get(CONF_APPS)
121 
122  entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator)
123 
124  async_add_entities([entity], update_before_add=True)
125  platform = entity_platform.async_get_current_platform()
126  platform.async_register_entity_service(
127  SERVICE_UPDATE_SETTING, UPDATE_SETTING_SCHEMA, "async_update_setting"
128  )
129 
130 
132  """Media Player implementation which performs REST requests to device."""
133 
134  _attr_has_entity_name = True
135  _attr_name = None
136  _received_device_info = False
137 
138  def __init__(
139  self,
140  config_entry: ConfigEntry,
141  device: VizioAsync,
142  name: str,
143  device_class: MediaPlayerDeviceClass,
144  apps_coordinator: VizioAppsDataUpdateCoordinator | None,
145  ) -> None:
146  """Initialize Vizio device."""
147  self._config_entry_config_entry = config_entry
148  self._apps_coordinator_apps_coordinator = apps_coordinator
149 
150  self._volume_step_volume_step = config_entry.options[CONF_VOLUME_STEP]
151  self._current_input_current_input: str | None = None
152  self._current_app_config_current_app_config: AppConfig | None = None
153  self._available_inputs_available_inputs: list[str] = []
154  self._available_apps_available_apps: list[str] = []
155  self._all_apps_all_apps = apps_coordinator.data if apps_coordinator else None
156  self._conf_apps_conf_apps = config_entry.options.get(CONF_APPS, {})
157  self._additional_app_configs_additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
158  CONF_ADDITIONAL_CONFIGS, []
159  )
160  self._device_device = device
161  self._max_volume_max_volume = float(device.get_max_volume())
162  self._attr_assumed_state_attr_assumed_state = True
163 
164  # Entity class attributes that will change with each update (we only include
165  # the ones that are initialized differently from the defaults)
166  self._attr_sound_mode_list_attr_sound_mode_list = []
167  self._attr_supported_features_attr_supported_features = SUPPORTED_COMMANDS[device_class]
168 
169  # Entity class attributes that will not change
170  unique_id = config_entry.unique_id
171  assert unique_id
172  self._attr_unique_id_attr_unique_id = unique_id
173  self._attr_device_class_attr_device_class = device_class
174  self._attr_device_info_attr_device_info = DeviceInfo(
175  identifiers={(DOMAIN, unique_id)},
176  manufacturer="VIZIO",
177  name=name,
178  )
179 
180  def _apps_list(self, apps: list[str]) -> list[str]:
181  """Return process apps list based on configured filters."""
182  if self._conf_apps_conf_apps.get(CONF_INCLUDE):
183  return [app for app in apps if app in self._conf_apps_conf_apps[CONF_INCLUDE]]
184 
185  if self._conf_apps_conf_apps.get(CONF_EXCLUDE):
186  return [app for app in apps if app not in self._conf_apps_conf_apps[CONF_EXCLUDE]]
187 
188  return apps
189 
190  async def async_update(self) -> None:
191  """Retrieve latest state of the device."""
192  if (
193  is_on := await self._device_device.get_power_state(log_api_exception=False)
194  ) is None:
195  if self._attr_available_attr_available:
196  _LOGGER.warning(
197  "Lost connection to %s", self._config_entry_config_entry.data[CONF_HOST]
198  )
199  self._attr_available_attr_available = False
200  return
201 
202  if not self._attr_available_attr_available:
203  _LOGGER.warning(
204  "Restored connection to %s", self._config_entry_config_entry.data[CONF_HOST]
205  )
206  self._attr_available_attr_available = True
207 
208  if not self._received_device_info_received_device_info_received_device_info:
209  device_reg = dr.async_get(self.hasshass)
210  assert self._config_entry_config_entry.unique_id
211  device = device_reg.async_get_device(
212  identifiers={(DOMAIN, self._config_entry_config_entry.unique_id)}
213  )
214  if device:
215  device_reg.async_update_device(
216  device.id,
217  model=await self._device_device.get_model_name(log_api_exception=False),
218  sw_version=await self._device_device.get_version(log_api_exception=False),
219  )
220  self._received_device_info_received_device_info_received_device_info = True
221 
222  if not is_on:
223  self._attr_state_attr_state = MediaPlayerState.OFF
224  self._attr_volume_level_attr_volume_level = None
225  self._attr_is_volume_muted_attr_is_volume_muted = None
226  self._current_input_current_input = None
227  self._attr_app_name_attr_app_name = None
228  self._current_app_config_current_app_config = None
229  self._attr_sound_mode_attr_sound_mode = None
230  return
231 
232  self._attr_state_attr_state = MediaPlayerState.ON
233 
234  if audio_settings := await self._device_device.get_all_settings(
235  VIZIO_AUDIO_SETTINGS, log_api_exception=False
236  ):
237  self._attr_volume_level_attr_volume_level = (
238  float(audio_settings[VIZIO_VOLUME]) / self._max_volume_max_volume
239  )
240  if VIZIO_MUTE in audio_settings:
241  self._attr_is_volume_muted_attr_is_volume_muted = (
242  audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON
243  )
244  else:
245  self._attr_is_volume_muted_attr_is_volume_muted = None
246 
247  if VIZIO_SOUND_MODE in audio_settings:
248  self._attr_supported_features_attr_supported_features |= (
249  MediaPlayerEntityFeature.SELECT_SOUND_MODE
250  )
251  self._attr_sound_mode_attr_sound_mode = audio_settings[VIZIO_SOUND_MODE]
252  if not self._attr_sound_mode_list_attr_sound_mode_list:
253  self._attr_sound_mode_list_attr_sound_mode_list = await self._device_device.get_setting_options(
254  VIZIO_AUDIO_SETTINGS,
255  VIZIO_SOUND_MODE,
256  log_api_exception=False,
257  )
258  else:
259  # Explicitly remove MediaPlayerEntityFeature.SELECT_SOUND_MODE from supported features
260  self._attr_supported_features_attr_supported_features &= (
261  ~MediaPlayerEntityFeature.SELECT_SOUND_MODE
262  )
263 
264  if input_ := await self._device_device.get_current_input(log_api_exception=False):
265  self._current_input_current_input = input_
266 
267  # If no inputs returned, end update
268  if not (inputs := await self._device_device.get_inputs_list(log_api_exception=False)):
269  return
270 
271  self._available_inputs_available_inputs = [input_.name for input_ in inputs]
272 
273  # Return before setting app variables if INPUT_APPS isn't in available inputs
274  if self._attr_device_class_attr_device_class == MediaPlayerDeviceClass.SPEAKER or not any(
275  app for app in INPUT_APPS if app in self._available_inputs_available_inputs
276  ):
277  return
278 
279  # Create list of available known apps from known app list after
280  # filtering by CONF_INCLUDE/CONF_EXCLUDE
281  self._available_apps_available_apps = self._apps_list_apps_list(
282  [app["name"] for app in self._all_apps_all_apps or ()]
283  )
284 
285  self._current_app_config_current_app_config = await self._device_device.get_current_app_config(
286  log_api_exception=False
287  )
288 
289  self._attr_app_name_attr_app_name = find_app_name(
290  self._current_app_config_current_app_config,
291  [APP_HOME, *(self._all_apps_all_apps or ()), *self._additional_app_configs_additional_app_configs],
292  )
293 
294  if self._attr_app_name_attr_app_name == NO_APP_RUNNING:
295  self._attr_app_name_attr_app_name = None
296 
297  def _get_additional_app_names(self) -> list[str]:
298  """Return list of additional apps that were included in configuration.yaml."""
299  return [
300  additional_app["name"] for additional_app in self._additional_app_configs_additional_app_configs
301  ]
302 
303  @staticmethod
305  hass: HomeAssistant, config_entry: ConfigEntry
306  ) -> None:
307  """Send update event when Vizio config entry is updated."""
308  # Move this method to component level if another entity ever gets added for a
309  # single config entry.
310  # See here: https://github.com/home-assistant/core/pull/30653#discussion_r366426121
311  async_dispatcher_send(hass, config_entry.entry_id, config_entry)
312 
313  async def _async_update_options(self, config_entry: ConfigEntry) -> None:
314  """Update options if the update signal comes from this entity."""
315  self._volume_step_volume_step = config_entry.options[CONF_VOLUME_STEP]
316  # Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports
317  self._conf_apps_conf_apps.update(config_entry.options.get(CONF_APPS, {}))
318 
320  self, setting_type: str, setting_name: str, new_value: int | str
321  ) -> None:
322  """Update a setting when update_setting service is called."""
323  await self._device_device.set_setting(
324  setting_type,
325  setting_name,
326  new_value,
327  log_api_exception=False,
328  )
329 
330  async def async_added_to_hass(self) -> None:
331  """Register callbacks when entity is added."""
332  # Register callback for when config entry is updated.
333  self.async_on_removeasync_on_remove(
334  self._config_entry_config_entry.add_update_listener(
335  self._async_send_update_options_signal_async_send_update_options_signal
336  )
337  )
338 
339  # Register callback for update event
340  self.async_on_removeasync_on_remove(
342  self.hasshass, self._config_entry_config_entry.entry_id, self._async_update_options_async_update_options
343  )
344  )
345 
346  if not self._apps_coordinator_apps_coordinator:
347  return
348 
349  # Register callback for app list updates if device is a TV
350  @callback
351  def apps_list_update() -> None:
352  """Update list of all apps."""
353  if not self._apps_coordinator_apps_coordinator:
354  return
355  self._all_apps_all_apps = self._apps_coordinator_apps_coordinator.data
356  self.async_write_ha_stateasync_write_ha_state()
357 
358  self.async_on_removeasync_on_remove(
359  self._apps_coordinator_apps_coordinator.async_add_listener(apps_list_update)
360  )
361 
362  @property
363  def source(self) -> str | None:
364  """Return current input of the device."""
365  if self._attr_app_name_attr_app_name is not None and self._current_input_current_input in INPUT_APPS:
366  return self._attr_app_name_attr_app_name
367 
368  return self._current_input_current_input
369 
370  @property
371  def source_list(self) -> list[str]:
372  """Return list of available inputs of the device."""
373  # If Smartcast app is in input list, and the app list has been retrieved,
374  # show the combination with, otherwise just return inputs
375  if self._available_apps_available_apps:
376  return [
377  *(
378  _input
379  for _input in self._available_inputs_available_inputs
380  if _input not in INPUT_APPS
381  ),
382  *self._available_apps_available_apps,
383  *(
384  app
385  for app in self._get_additional_app_names_get_additional_app_names()
386  if app not in self._available_apps_available_apps
387  ),
388  ]
389 
390  return self._available_inputs_available_inputs
391 
392  @property
393  def app_id(self):
394  """Return the ID of the current app if it is unknown by pyvizio."""
395  if self._current_app_config_current_app_config and self.sourcesourcesourcesource == UNKNOWN_APP:
396  return {
397  "APP_ID": self._current_app_config_current_app_config.APP_ID,
398  "NAME_SPACE": self._current_app_config_current_app_config.NAME_SPACE,
399  "MESSAGE": self._current_app_config_current_app_config.MESSAGE,
400  }
401 
402  return None
403 
404  async def async_select_sound_mode(self, sound_mode: str) -> None:
405  """Select sound mode."""
406  if sound_mode in (self._attr_sound_mode_list_attr_sound_mode_list or ()):
407  await self._device_device.set_setting(
408  VIZIO_AUDIO_SETTINGS,
409  VIZIO_SOUND_MODE,
410  sound_mode,
411  log_api_exception=False,
412  )
413 
414  async def async_turn_on(self) -> None:
415  """Turn the device on."""
416  await self._device_device.pow_on(log_api_exception=False)
417 
418  async def async_turn_off(self) -> None:
419  """Turn the device off."""
420  await self._device_device.pow_off(log_api_exception=False)
421 
422  async def async_mute_volume(self, mute: bool) -> None:
423  """Mute the volume."""
424  if mute:
425  await self._device_device.mute_on(log_api_exception=False)
426  self._attr_is_volume_muted_attr_is_volume_muted = True
427  else:
428  await self._device_device.mute_off(log_api_exception=False)
429  self._attr_is_volume_muted_attr_is_volume_muted = False
430 
431  async def async_media_previous_track(self) -> None:
432  """Send previous channel command."""
433  await self._device_device.ch_down(log_api_exception=False)
434 
435  async def async_media_next_track(self) -> None:
436  """Send next channel command."""
437  await self._device_device.ch_up(log_api_exception=False)
438 
439  async def async_select_source(self, source: str) -> None:
440  """Select input source."""
441  if source in self._available_inputs_available_inputs:
442  await self._device_device.set_input(source, log_api_exception=False)
443  elif source in self._get_additional_app_names_get_additional_app_names():
444  await self._device_device.launch_app_config(
445  **next(
446  app["config"]
447  for app in self._additional_app_configs_additional_app_configs
448  if app["name"] == source
449  ),
450  log_api_exception=False,
451  )
452  elif source in self._available_apps_available_apps:
453  await self._device_device.launch_app(
454  source, self._all_apps_all_apps, log_api_exception=False
455  )
456 
457  async def async_volume_up(self) -> None:
458  """Increase volume of the device."""
459  await self._device_device.vol_up(num=self._volume_step_volume_step, log_api_exception=False)
460 
461  if self._attr_volume_level_attr_volume_level is not None:
462  self._attr_volume_level_attr_volume_level = min(
463  1.0, self._attr_volume_level_attr_volume_level + self._volume_step_volume_step / self._max_volume_max_volume
464  )
465 
466  async def async_volume_down(self) -> None:
467  """Decrease volume of the device."""
468  await self._device_device.vol_down(num=self._volume_step_volume_step, log_api_exception=False)
469 
470  if self._attr_volume_level_attr_volume_level is not None:
471  self._attr_volume_level_attr_volume_level = max(
472  0.0, self._attr_volume_level_attr_volume_level - self._volume_step_volume_step / self._max_volume_max_volume
473  )
474 
475  async def async_set_volume_level(self, volume: float) -> None:
476  """Set volume level."""
477  if self._attr_volume_level_attr_volume_level is not None:
478  if volume > self._attr_volume_level_attr_volume_level:
479  num = int(self._max_volume_max_volume * (volume - self._attr_volume_level_attr_volume_level))
480  await self._device_device.vol_up(num=num, log_api_exception=False)
481  self._attr_volume_level_attr_volume_level = volume
482 
483  elif volume < self._attr_volume_level_attr_volume_level:
484  num = int(self._max_volume_max_volume * (self._attr_volume_level_attr_volume_level - volume))
485  await self._device_device.vol_down(num=num, log_api_exception=False)
486  self._attr_volume_level_attr_volume_level = volume
487 
488  async def async_media_play(self) -> None:
489  """Play whatever media is currently active."""
490  await self._device_device.play(log_api_exception=False)
491 
492  async def async_media_pause(self) -> None:
493  """Pause whatever media is currently active."""
494  await self._device_device.pause(log_api_exception=False)
None __init__(self, ConfigEntry config_entry, VizioAsync device, str name, MediaPlayerDeviceClass device_class, VizioAppsDataUpdateCoordinator|None apps_coordinator)
None _async_send_update_options_signal(HomeAssistant hass, ConfigEntry config_entry)
None async_update_setting(self, str setting_type, str setting_name, int|str new_value)
None _async_update_options(self, ConfigEntry config_entry)
None async_on_remove(self, CALLBACK_TYPE func)
Definition: entity.py:1331
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
IssData update(pyiss.ISS iss)
Definition: __init__.py:33
None async_add_listener(HomeAssistant hass, Callable[[], None] listener)
Definition: __init__.py:82
str get_model_name(dict[str, Any] info)
Definition: utils.py:308
None async_setup_entry(HomeAssistant hass, ConfigEntry config_entry, AddEntitiesCallback async_add_entities)
Definition: media_player.py:67
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)
Callable[[], None] async_dispatcher_connect(HomeAssistant hass, str signal, Callable[..., Any] target)
Definition: dispatcher.py:103
None async_dispatcher_send(HomeAssistant hass, str signal, *Any args)
Definition: dispatcher.py:193