Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for Yamaha Receivers."""
2 
3 from __future__ import annotations
4 
5 import logging
6 from typing import Any
7 
8 import requests
9 import rxv
10 from rxv import RXV
11 import voluptuous as vol
12 
14  PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
15  MediaPlayerEntity,
16  MediaPlayerEntityFeature,
17  MediaPlayerState,
18  MediaType,
19 )
20 from homeassistant.const import CONF_HOST, CONF_NAME
21 from homeassistant.core import HomeAssistant
22 from homeassistant.exceptions import PlatformNotReady
23 from homeassistant.helpers import config_validation as cv, entity_platform
24 from homeassistant.helpers.entity_platform import AddEntitiesCallback
25 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
26 
27 from .const import (
28  CURSOR_TYPE_DOWN,
29  CURSOR_TYPE_LEFT,
30  CURSOR_TYPE_RETURN,
31  CURSOR_TYPE_RIGHT,
32  CURSOR_TYPE_SELECT,
33  CURSOR_TYPE_UP,
34  DISCOVER_TIMEOUT,
35  DOMAIN,
36  KNOWN_ZONES,
37  SERVICE_ENABLE_OUTPUT,
38  SERVICE_MENU_CURSOR,
39  SERVICE_SELECT_SCENE,
40 )
41 
42 _LOGGER = logging.getLogger(__name__)
43 
44 ATTR_CURSOR = "cursor"
45 ATTR_ENABLED = "enabled"
46 ATTR_PORT = "port"
47 
48 ATTR_SCENE = "scene"
49 
50 CONF_SOURCE_IGNORE = "source_ignore"
51 CONF_SOURCE_NAMES = "source_names"
52 CONF_ZONE_IGNORE = "zone_ignore"
53 CONF_ZONE_NAMES = "zone_names"
54 
55 CURSOR_TYPE_MAP = {
56  CURSOR_TYPE_DOWN: rxv.RXV.menu_down.__name__,
57  CURSOR_TYPE_LEFT: rxv.RXV.menu_left.__name__,
58  CURSOR_TYPE_RETURN: rxv.RXV.menu_return.__name__,
59  CURSOR_TYPE_RIGHT: rxv.RXV.menu_right.__name__,
60  CURSOR_TYPE_SELECT: rxv.RXV.menu_sel.__name__,
61  CURSOR_TYPE_UP: rxv.RXV.menu_up.__name__,
62 }
63 DEFAULT_NAME = "Yamaha Receiver"
64 
65 SUPPORT_YAMAHA = (
66  MediaPlayerEntityFeature.VOLUME_SET
67  | MediaPlayerEntityFeature.VOLUME_MUTE
68  | MediaPlayerEntityFeature.TURN_ON
69  | MediaPlayerEntityFeature.TURN_OFF
70  | MediaPlayerEntityFeature.SELECT_SOURCE
71  | MediaPlayerEntityFeature.PLAY
72  | MediaPlayerEntityFeature.SELECT_SOUND_MODE
73 )
74 
75 PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
76  {
77  vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
78  vol.Optional(CONF_HOST): cv.string,
79  vol.Optional(CONF_SOURCE_IGNORE, default=[]): vol.All(
80  cv.ensure_list, [cv.string]
81  ),
82  vol.Optional(CONF_ZONE_IGNORE, default=[]): vol.All(
83  cv.ensure_list, [cv.string]
84  ),
85  vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string},
86  vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string},
87  }
88 )
89 
90 
92  """Configuration Info for Yamaha Receivers."""
93 
94  def __init__(
95  self, config: ConfigType, discovery_info: DiscoveryInfoType | None
96  ) -> None:
97  """Initialize the Configuration Info for Yamaha Receiver."""
98  self.namename = config.get(CONF_NAME)
99  self.hosthost = config.get(CONF_HOST)
100  self.ctrl_urlctrl_url: str | None = f"http://{self.host}:80/YamahaRemoteControl/ctrl"
101  self.source_ignoresource_ignore = config.get(CONF_SOURCE_IGNORE)
102  self.source_namessource_names = config.get(CONF_SOURCE_NAMES)
103  self.zone_ignorezone_ignore = config.get(CONF_ZONE_IGNORE)
104  self.zone_nameszone_names = config.get(CONF_ZONE_NAMES)
105  self.from_discoveryfrom_discovery = False
106  _LOGGER.debug("Discovery Info: %s", discovery_info)
107  if discovery_info is not None:
108  self.namename = discovery_info.get("name")
109  self.modelmodel = discovery_info.get("model_name")
110  self.ctrl_urlctrl_url = discovery_info.get("control_url")
111  self.desc_urldesc_url = discovery_info.get("description_url")
112  self.zone_ignorezone_ignore = []
113  self.from_discoveryfrom_discovery = True
114 
115 
116 def _discovery(config_info: YamahaConfigInfo) -> list[RXV]:
117  """Discover list of zone controllers from configuration in the network."""
118  if config_info.from_discovery:
119  _LOGGER.debug("Discovery Zones")
120  zones = rxv.RXV(
121  config_info.ctrl_url,
122  model_name=config_info.model,
123  friendly_name=config_info.name,
124  unit_desc_url=config_info.desc_url,
125  ).zone_controllers()
126  elif config_info.host is None:
127  _LOGGER.debug("Config No Host Supplied Zones")
128  zones = []
129  for recv in rxv.find(DISCOVER_TIMEOUT):
130  zones.extend(recv.zone_controllers())
131  else:
132  _LOGGER.debug("Config Zones")
133  zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers()
134 
135  _LOGGER.debug("Returned _discover zones: %s", zones)
136  return zones
137 
138 
140  hass: HomeAssistant,
141  config: ConfigType,
142  async_add_entities: AddEntitiesCallback,
143  discovery_info: DiscoveryInfoType | None = None,
144 ) -> None:
145  """Set up the Yamaha platform."""
146  # Keep track of configured receivers so that we don't end up
147  # discovering a receiver dynamically that we have static config
148  # for. Map each device from its zone_id .
149  known_zones = hass.data.setdefault(DOMAIN, {KNOWN_ZONES: set()})[KNOWN_ZONES]
150  _LOGGER.debug("Known receiver zones: %s", known_zones)
151 
152  # Get the Infos for configuration from config (YAML) or Discovery
153  config_info = YamahaConfigInfo(config=config, discovery_info=discovery_info)
154  # Async check if the Receivers are there in the network
155  try:
156  zone_ctrls = await hass.async_add_executor_job(_discovery, config_info)
157  except requests.exceptions.ConnectionError as ex:
158  raise PlatformNotReady(f"Issue while connecting to {config_info.name}") from ex
159 
160  entities = []
161  for zctrl in zone_ctrls:
162  _LOGGER.debug("Receiver zone: %s serial %s", zctrl.zone, zctrl.serial_number)
163  if config_info.zone_ignore and zctrl.zone in config_info.zone_ignore:
164  _LOGGER.debug("Ignore receiver zone: %s %s", config_info.name, zctrl.zone)
165  continue
166 
167  assert config_info.name
168  entity = YamahaDeviceZone(
169  config_info.name,
170  zctrl,
171  config_info.source_ignore,
172  config_info.source_names,
173  config_info.zone_names,
174  )
175 
176  # Only add device if it's not already added
177  if entity.zone_id not in known_zones:
178  known_zones.add(entity.zone_id)
179  entities.append(entity)
180  else:
181  _LOGGER.debug(
182  "Ignoring duplicate zone: %s %s", config_info.name, zctrl.zone
183  )
184 
185  async_add_entities(entities)
186 
187  # Register Service 'select_scene'
188  platform = entity_platform.async_get_current_platform()
189  platform.async_register_entity_service(
190  SERVICE_SELECT_SCENE,
191  {vol.Required(ATTR_SCENE): cv.string},
192  "set_scene",
193  )
194  # Register Service 'enable_output'
195  platform.async_register_entity_service(
196  SERVICE_ENABLE_OUTPUT,
197  {vol.Required(ATTR_ENABLED): cv.boolean, vol.Required(ATTR_PORT): cv.string},
198  "enable_output",
199  )
200  # Register Service 'menu_cursor'
201  platform.async_register_entity_service(
202  SERVICE_MENU_CURSOR,
203  {vol.Required(ATTR_CURSOR): vol.In(CURSOR_TYPE_MAP)},
204  YamahaDeviceZone.menu_cursor.__name__,
205  )
206 
207 
209  """Representation of a Yamaha device zone."""
210 
211  _reverse_mapping: dict[str, str]
212 
213  def __init__(
214  self,
215  name: str,
216  zctrl: RXV,
217  source_ignore: list[str] | None,
218  source_names: dict[str, str] | None,
219  zone_names: dict[str, str] | None,
220  ) -> None:
221  """Initialize the Yamaha Receiver."""
222  self.zctrlzctrl = zctrl
223  self._attr_is_volume_muted_attr_is_volume_muted = False
224  self._attr_volume_level_attr_volume_level = 0
225  self._attr_state_attr_state = MediaPlayerState.OFF
226  self._source_ignore: list[str] = source_ignore or []
227  self._source_names: dict[str, str] = source_names or {}
228  self._zone_names: dict[str, str] = zone_names or {}
229  self._playback_support_playback_support = None
230  self._is_playback_supported_is_playback_supported = False
231  self._play_status_play_status = None
232  self._name_name = name
233  self._zone_zone = zctrl.zone
234  if self.zctrlzctrl.serial_number is not None:
235  # Since not all receivers will have a serial number and set a unique id
236  # the default name of the integration may not be changed
237  # to avoid a breaking change.
238  self._attr_unique_id_attr_unique_id = f"{self.zctrl.serial_number}_{self._zone}"
239 
240  def update(self) -> None:
241  """Get the latest details from the device."""
242  try:
243  self._play_status_play_status = self.zctrlzctrl.play_status()
244  except requests.exceptions.ConnectionError:
245  _LOGGER.debug("Receiver is offline: %s", self._name_name)
246  self._attr_available_attr_available = False
247  return
248 
249  self._attr_available_attr_available = True
250  if self.zctrlzctrl.on:
251  if self._play_status_play_status is None:
252  self._attr_state_attr_state = MediaPlayerState.ON
253  elif self._play_status_play_status.playing:
254  self._attr_state_attr_state = MediaPlayerState.PLAYING
255  else:
256  self._attr_state_attr_state = MediaPlayerState.IDLE
257  else:
258  self._attr_state_attr_state = MediaPlayerState.OFF
259 
260  self._attr_is_volume_muted_attr_is_volume_muted = self.zctrlzctrl.mute
261  self._attr_volume_level_attr_volume_level = (self.zctrlzctrl.volume / 100) + 1
262 
263  if self.source_listsource_list is None:
264  self.build_source_listbuild_source_list()
265 
266  current_source = self.zctrlzctrl.input
267  self._attr_source_attr_source = self._source_names.get(current_source, current_source)
268  self._playback_support_playback_support = self.zctrlzctrl.get_playback_support()
269  self._is_playback_supported_is_playback_supported = self.zctrlzctrl.is_playback_supported(
270  self._attr_source_attr_source
271  )
272  surround_programs = self.zctrlzctrl.surround_programs()
273  if surround_programs:
274  self._attr_sound_mode_attr_sound_mode = self.zctrlzctrl.surround_program
275  self._attr_sound_mode_list_attr_sound_mode_list = surround_programs
276  else:
277  self._attr_sound_mode_attr_sound_mode = None
278  self._attr_sound_mode_list_attr_sound_mode_list = None
279 
280  def build_source_list(self) -> None:
281  """Build the source list."""
282  self._reverse_mapping_reverse_mapping = {
283  alias: source for source, alias in self._source_names.items()
284  }
285 
286  self._attr_source_list_attr_source_list = sorted(
287  self._source_names.get(source, source)
288  for source in self.zctrlzctrl.inputs()
289  if source not in self._source_ignore
290  )
291 
292  @property
293  def name(self) -> str:
294  """Return the name of the device."""
295  name = self._name_name
296  zone_name = self._zone_names.get(self._zone_zone, self._zone_zone)
297  if zone_name != "Main_Zone":
298  # Zone will be one of Main_Zone, Zone_2, Zone_3
299  name += f" {zone_name.replace('_', ' ')}"
300  return name
301 
302  @property
303  def zone_id(self) -> str:
304  """Return a zone_id to ensure 1 media player per zone."""
305  return f"{self.zctrl.ctrl_url}:{self._zone}"
306 
307  @property
308  def supported_features(self) -> MediaPlayerEntityFeature:
309  """Flag media player features that are supported."""
310  supported_features = SUPPORT_YAMAHA
311 
312  supports = self._playback_support_playback_support
313  mapping = {
314  "play": (
315  MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA
316  ),
317  "pause": MediaPlayerEntityFeature.PAUSE,
318  "stop": MediaPlayerEntityFeature.STOP,
319  "skip_f": MediaPlayerEntityFeature.NEXT_TRACK,
320  "skip_r": MediaPlayerEntityFeature.PREVIOUS_TRACK,
321  }
322  for attr, feature in mapping.items():
323  if getattr(supports, attr, False):
324  supported_features |= feature
325  return supported_features
326 
327  def turn_off(self) -> None:
328  """Turn off media player."""
329  self.zctrlzctrl.on = False
330 
331  def set_volume_level(self, volume: float) -> None:
332  """Set volume level, range 0..1."""
333  zone_vol = 100 - (volume * 100)
334  negative_zone_vol = -zone_vol
335  self.zctrlzctrl.volume = negative_zone_vol
336 
337  def mute_volume(self, mute: bool) -> None:
338  """Mute (true) or unmute (false) media player."""
339  self.zctrlzctrl.mute = mute
340 
341  def turn_on(self) -> None:
342  """Turn the media player on."""
343  self.zctrlzctrl.on = True
344  self._attr_volume_level_attr_volume_level = (self.zctrlzctrl.volume / 100) + 1
345 
346  def media_play(self) -> None:
347  """Send play command."""
348  self._call_playback_function_call_playback_function(self.zctrlzctrl.play, "play")
349 
350  def media_pause(self) -> None:
351  """Send pause command."""
352  self._call_playback_function_call_playback_function(self.zctrlzctrl.pause, "pause")
353 
354  def media_stop(self) -> None:
355  """Send stop command."""
356  self._call_playback_function_call_playback_function(self.zctrlzctrl.stop, "stop")
357 
358  def media_previous_track(self) -> None:
359  """Send previous track command."""
360  self._call_playback_function_call_playback_function(self.zctrlzctrl.previous, "previous track")
361 
362  def media_next_track(self) -> None:
363  """Send next track command."""
364  self._call_playback_function_call_playback_function(self.zctrlzctrl.next, "next track")
365 
366  def _call_playback_function(self, function, function_text):
367  try:
368  function()
369  except rxv.exceptions.ResponseException:
370  _LOGGER.warning("Failed to execute %s on %s", function_text, self._name_name)
371 
372  def select_source(self, source: str) -> None:
373  """Select input source."""
374  self.zctrlzctrl.input = self._reverse_mapping_reverse_mapping.get(source, source)
375 
377  self, media_type: MediaType | str, media_id: str, **kwargs: Any
378  ) -> None:
379  """Play media from an ID.
380 
381  This exposes a pass through for various input sources in the
382  Yamaha to direct play certain kinds of media. media_type is
383  treated as the input type that we are setting, and media id is
384  specific to it.
385  For the NET RADIO mediatype the format for ``media_id`` is a
386  "path" in your vtuner hierarchy. For instance:
387  ``Bookmarks>Internet>Radio Paradise``. The separators are
388  ``>`` and the parts of this are navigated by name behind the
389  scenes. There is a looping construct built into the yamaha
390  library to do this with a fallback timeout if the vtuner
391  service is unresponsive.
392  NOTE: this might take a while, because the only API interface
393  for setting the net radio station emulates button pressing and
394  navigating through the net radio menu hierarchy. And each sub
395  menu must be fetched by the receiver from the vtuner service.
396  """
397  if media_type == "NET RADIO":
398  self.zctrlzctrl.net_radio(media_id)
399 
400  def enable_output(self, port: str, enabled: bool) -> None:
401  """Enable or disable an output port.."""
402  self.zctrlzctrl.enable_output(port, enabled)
403 
404  def menu_cursor(self, cursor: str) -> None:
405  """Press a menu cursor button."""
406  getattr(self.zctrlzctrl, CURSOR_TYPE_MAP[cursor])()
407 
408  def set_scene(self, scene: str) -> None:
409  """Set the current scene."""
410  try:
411  self.zctrlzctrl.scene = scene
412  except AssertionError:
413  _LOGGER.warning("Scene '%s' does not exist!", scene)
414 
415  def select_sound_mode(self, sound_mode: str) -> None:
416  """Set Sound Mode for Receiver.."""
417  self.zctrlzctrl.surround_program = sound_mode
418 
419  @property
420  def media_artist(self) -> str | None:
421  """Artist of current playing media."""
422  if self._play_status_play_status is not None:
423  return self._play_status_play_status.artist
424  return None
425 
426  @property
427  def media_album_name(self) -> str | None:
428  """Album of current playing media."""
429  if self._play_status_play_status is not None:
430  return self._play_status_play_status.album
431  return None
432 
433  @property
434  def media_content_type(self) -> MediaType | None:
435  """Content type of current playing media."""
436  # Loose assumption that if playback is supported, we are playing music
437  if self._is_playback_supported_is_playback_supported:
438  return MediaType.MUSIC
439  return None
440 
441  @property
442  def media_title(self) -> str | None:
443  """Artist of current playing media."""
444  if self._play_status_play_status is not None:
445  song = self._play_status_play_status.song
446  station = self._play_status_play_status.station
447 
448  # If both song and station is available, print both, otherwise
449  # just the one we have.
450  if song and station:
451  return f"{station}: {song}"
452 
453  return song or station
454  return None
None __init__(self, ConfigType config, DiscoveryInfoType|None discovery_info)
Definition: media_player.py:96
def _call_playback_function(self, function, function_text)
None __init__(self, str name, RXV zctrl, list[str]|None source_ignore, dict[str, str]|None source_names, dict[str, str]|None zone_names)
None play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
web.Response get(self, web.Request request, str config_key)
Definition: view.py:88
list[RXV] _discovery(YamahaConfigInfo config_info)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)