Home Assistant Unofficial Reference 2024.12.1
media_player.py
Go to the documentation of this file.
1 """Support for Onkyo Receivers."""
2 
3 from __future__ import annotations
4 
5 import asyncio
6 import logging
7 from typing import Any, Literal
8 
9 import voluptuous as vol
10 
12  PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA,
13  MediaPlayerEntity,
14  MediaPlayerEntityFeature,
15  MediaPlayerState,
16  MediaType,
17 )
18 from homeassistant.config_entries import SOURCE_IMPORT
19 from homeassistant.const import CONF_HOST, CONF_NAME
20 from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
21 from homeassistant.data_entry_flow import FlowResultType
22 from homeassistant.helpers import config_validation as cv, entity_registry as er
23 from homeassistant.helpers.entity_platform import AddEntitiesCallback
24 from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
25 from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
26 
27 from . import OnkyoConfigEntry
28 from .const import (
29  CONF_RECEIVER_MAX_VOLUME,
30  CONF_SOURCES,
31  DOMAIN,
32  OPTION_MAX_VOLUME,
33  OPTION_VOLUME_RESOLUTION,
34  PYEISCP_COMMANDS,
35  ZONES,
36  InputSource,
37  VolumeResolution,
38 )
39 from .receiver import Receiver, async_discover
40 from .services import DATA_MP_ENTITIES
41 
42 _LOGGER = logging.getLogger(__name__)
43 
44 CONF_MAX_VOLUME_DEFAULT = 100
45 CONF_RECEIVER_MAX_VOLUME_DEFAULT = 80
46 CONF_SOURCES_DEFAULT = {
47  "tv": "TV",
48  "bd": "Bluray",
49  "game": "Game",
50  "aux1": "Aux1",
51  "video1": "Video 1",
52  "video2": "Video 2",
53  "video3": "Video 3",
54  "video4": "Video 4",
55  "video5": "Video 5",
56  "video6": "Video 6",
57  "video7": "Video 7",
58  "fm": "Radio",
59 }
60 
61 PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
62  {
63  vol.Optional(CONF_HOST): cv.string,
64  vol.Optional(CONF_NAME): cv.string,
65  vol.Optional(OPTION_MAX_VOLUME, default=CONF_MAX_VOLUME_DEFAULT): vol.All(
66  vol.Coerce(int), vol.Range(min=1, max=100)
67  ),
68  vol.Optional(
69  CONF_RECEIVER_MAX_VOLUME, default=CONF_RECEIVER_MAX_VOLUME_DEFAULT
70  ): cv.positive_int,
71  vol.Optional(CONF_SOURCES, default=CONF_SOURCES_DEFAULT): {
72  cv.string: cv.string
73  },
74  }
75 )
76 
77 SUPPORT_ONKYO_WO_VOLUME = (
78  MediaPlayerEntityFeature.TURN_ON
79  | MediaPlayerEntityFeature.TURN_OFF
80  | MediaPlayerEntityFeature.SELECT_SOURCE
81  | MediaPlayerEntityFeature.PLAY_MEDIA
82 )
83 SUPPORT_ONKYO = (
84  SUPPORT_ONKYO_WO_VOLUME
85  | MediaPlayerEntityFeature.VOLUME_SET
86  | MediaPlayerEntityFeature.VOLUME_MUTE
87  | MediaPlayerEntityFeature.VOLUME_STEP
88 )
89 
90 DEFAULT_PLAYABLE_SOURCES = (
91  InputSource.from_meaning("FM"),
92  InputSource.from_meaning("AM"),
93  InputSource.from_meaning("TUNER"),
94 )
95 
96 ATTR_PRESET = "preset"
97 ATTR_AUDIO_INFORMATION = "audio_information"
98 ATTR_VIDEO_INFORMATION = "video_information"
99 ATTR_VIDEO_OUT = "video_out"
100 
101 AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME = 8
102 
103 AUDIO_INFORMATION_MAPPING = [
104  "audio_input_port",
105  "input_signal_format",
106  "input_frequency",
107  "input_channels",
108  "listening_mode",
109  "output_channels",
110  "output_frequency",
111  "precision_quartz_lock_system",
112  "auto_phase_control_delay",
113  "auto_phase_control_phase",
114 ]
115 
116 VIDEO_INFORMATION_MAPPING = [
117  "video_input_port",
118  "input_resolution",
119  "input_color_schema",
120  "input_color_depth",
121  "video_output_port",
122  "output_resolution",
123  "output_color_schema",
124  "output_color_depth",
125  "picture_mode",
126 ]
127 ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo"
128 
129 type InputLibValue = str | tuple[str, ...]
130 
131 
132 def _input_lib_cmds(zone: str) -> dict[InputSource, InputLibValue]:
133  match zone:
134  case "main":
135  cmds = PYEISCP_COMMANDS["main"]["SLI"]
136  case "zone2":
137  cmds = PYEISCP_COMMANDS["zone2"]["SLZ"]
138  case "zone3":
139  cmds = PYEISCP_COMMANDS["zone3"]["SL3"]
140  case "zone4":
141  cmds = PYEISCP_COMMANDS["zone4"]["SL4"]
142 
143  result: dict[InputSource, InputLibValue] = {}
144  for k, v in cmds["values"].items():
145  try:
146  source = InputSource(k)
147  except ValueError:
148  continue
149  result[source] = v["name"]
150 
151  return result
152 
153 
155  hass: HomeAssistant,
156  config: ConfigType,
157  async_add_entities: AddEntitiesCallback,
158  discovery_info: DiscoveryInfoType | None = None,
159 ) -> None:
160  """Import config from yaml."""
161  host = config.get(CONF_HOST)
162 
163  source_mapping: dict[str, InputSource] = {}
164  for zone in ZONES:
165  for source, source_lib in _input_lib_cmds(zone).items():
166  if isinstance(source_lib, str):
167  source_mapping.setdefault(source_lib, source)
168  else:
169  for source_lib_single in source_lib:
170  source_mapping.setdefault(source_lib_single, source)
171 
172  sources: dict[InputSource, str] = {}
173  for source_lib_single, source_name in config[CONF_SOURCES].items():
174  user_source = source_mapping.get(source_lib_single.lower())
175  if user_source is not None:
176  sources[user_source] = source_name
177 
178  config[CONF_SOURCES] = sources
179 
180  results = []
181  if host is not None:
182  _LOGGER.debug("Importing yaml single: %s", host)
183  result = await hass.config_entries.flow.async_init(
184  DOMAIN, context={"source": SOURCE_IMPORT}, data=config
185  )
186  results.append((host, result))
187  else:
188  for info in await async_discover():
189  host = info.host
190 
191  # Migrate legacy entities.
192  registry = er.async_get(hass)
193  old_unique_id = f"{info.model_name}_{info.identifier}"
194  new_unique_id = f"{info.identifier}_main"
195  entity_id = registry.async_get_entity_id(
196  "media_player", DOMAIN, old_unique_id
197  )
198  if entity_id is not None:
199  _LOGGER.debug(
200  "Migrating unique_id from [%s] to [%s] for entity %s",
201  old_unique_id,
202  new_unique_id,
203  entity_id,
204  )
205  registry.async_update_entity(entity_id, new_unique_id=new_unique_id)
206 
207  _LOGGER.debug("Importing yaml discover: %s", info.host)
208  result = await hass.config_entries.flow.async_init(
209  DOMAIN,
210  context={"source": SOURCE_IMPORT},
211  data=config | {CONF_HOST: info.host} | {"info": info},
212  )
213  results.append((host, result))
214 
215  _LOGGER.debug("Importing yaml results: %s", results)
216  if not results:
218  hass,
219  DOMAIN,
220  "deprecated_yaml_import_issue_no_discover",
221  breaks_in_ha_version="2025.5.0",
222  is_fixable=False,
223  issue_domain=DOMAIN,
224  severity=IssueSeverity.WARNING,
225  translation_key="deprecated_yaml_import_issue_no_discover",
226  translation_placeholders={"url": ISSUE_URL_PLACEHOLDER},
227  )
228 
229  all_successful = True
230  for host, result in results:
231  if (
232  result.get("type") == FlowResultType.CREATE_ENTRY
233  or result.get("reason") == "already_configured"
234  ):
235  continue
236  if error := result.get("reason"):
237  all_successful = False
239  hass,
240  DOMAIN,
241  f"deprecated_yaml_import_issue_{host}_{error}",
242  breaks_in_ha_version="2025.5.0",
243  is_fixable=False,
244  issue_domain=DOMAIN,
245  severity=IssueSeverity.WARNING,
246  translation_key=f"deprecated_yaml_import_issue_{error}",
247  translation_placeholders={
248  "host": host,
249  "url": ISSUE_URL_PLACEHOLDER,
250  },
251  )
252 
253  if all_successful:
255  hass,
256  HOMEASSISTANT_DOMAIN,
257  f"deprecated_yaml_{DOMAIN}",
258  is_fixable=False,
259  issue_domain=DOMAIN,
260  breaks_in_ha_version="2025.5.0",
261  severity=IssueSeverity.WARNING,
262  translation_key="deprecated_yaml",
263  translation_placeholders={
264  "domain": DOMAIN,
265  "integration_title": "onkyo",
266  },
267  )
268 
269 
271  hass: HomeAssistant,
272  entry: OnkyoConfigEntry,
273  async_add_entities: AddEntitiesCallback,
274 ) -> None:
275  """Set up MediaPlayer for config entry."""
276  data = entry.runtime_data
277 
278  receiver = data.receiver
279  all_entities = hass.data[DATA_MP_ENTITIES]
280 
281  entities: dict[str, OnkyoMediaPlayer] = {}
282  all_entities[entry.entry_id] = entities
283 
284  volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION]
285  max_volume: float = entry.options[OPTION_MAX_VOLUME]
286  sources = data.sources
287 
288  def connect_callback(receiver: Receiver) -> None:
289  if not receiver.first_connect:
290  for entity in entities.values():
291  if entity.enabled:
292  entity.backfill_state()
293 
294  def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None:
295  zone, _, value = message
296  entity = entities.get(zone)
297  if entity is not None:
298  if entity.enabled:
299  entity.process_update(message)
300  elif zone in ZONES and value != "N/A":
301  # When we receive the status for a zone, and the value is not "N/A",
302  # then zone is available on the receiver, so we create the entity for it.
303  _LOGGER.debug(
304  "Discovered %s on %s (%s)",
305  ZONES[zone],
306  receiver.model_name,
307  receiver.host,
308  )
309  zone_entity = OnkyoMediaPlayer(
310  receiver,
311  zone,
312  volume_resolution=volume_resolution,
313  max_volume=max_volume,
314  sources=sources,
315  )
316  entities[zone] = zone_entity
317  async_add_entities([zone_entity])
318 
319  receiver.callbacks.connect.append(connect_callback)
320  receiver.callbacks.update.append(update_callback)
321 
322 
324  """Representation of an Onkyo Receiver Media Player (one per each zone)."""
325 
326  _attr_should_poll = False
327 
328  _supports_volume: bool = False
329  _supports_audio_info: bool = False
330  _supports_video_info: bool = False
331  _query_timer: asyncio.TimerHandle | None = None
332 
333  def __init__(
334  self,
335  receiver: Receiver,
336  zone: str,
337  *,
338  volume_resolution: VolumeResolution,
339  max_volume: float,
340  sources: dict[InputSource, str],
341  ) -> None:
342  """Initialize the Onkyo Receiver."""
343  self._receiver_receiver = receiver
344  name = receiver.model_name
345  identifier = receiver.identifier
346  self._attr_name_attr_name = f"{name}{' ' + ZONES[zone] if zone != 'main' else ''}"
347  self._attr_unique_id_attr_unique_id = f"{identifier}_{zone}"
348 
349  self._zone_zone = zone
350 
351  self._volume_resolution_volume_resolution = volume_resolution
352  self._max_volume_max_volume = max_volume
353 
354  self._name_mapping_name_mapping = sources
355  self._reverse_name_mapping_reverse_name_mapping = {value: key for key, value in sources.items()}
356  self._lib_mapping_lib_mapping = _input_lib_cmds(zone)
357  self._reverse_lib_mapping_reverse_lib_mapping = {
358  value: key for key, value in self._lib_mapping_lib_mapping.items()
359  }
360 
361  self._attr_source_list_attr_source_list = list(sources.values())
362  self._attr_extra_state_attributes_attr_extra_state_attributes = {}
363 
364  async def async_added_to_hass(self) -> None:
365  """Entity has been added to hass."""
366  self.backfill_statebackfill_state()
367 
368  async def async_will_remove_from_hass(self) -> None:
369  """Cancel the query timer when the entity is removed."""
370  if self._query_timer_query_timer:
371  self._query_timer_query_timer.cancel()
372  self._query_timer_query_timer = None
373 
374  @property
375  def supported_features(self) -> MediaPlayerEntityFeature:
376  """Return media player features that are supported."""
377  if self._supports_volume_supports_volume:
378  return SUPPORT_ONKYO
379  return SUPPORT_ONKYO_WO_VOLUME
380 
381  @callback
382  def _update_receiver(self, propname: str, value: Any) -> None:
383  """Update a property in the receiver."""
384  self._receiver_receiver.conn.update_property(self._zone_zone, propname, value)
385 
386  @callback
387  def _query_receiver(self, propname: str) -> None:
388  """Cause the receiver to send an update about a property."""
389  self._receiver_receiver.conn.query_property(self._zone_zone, propname)
390 
391  async def async_turn_on(self) -> None:
392  """Turn the media player on."""
393  self._update_receiver_update_receiver("power", "on")
394 
395  async def async_turn_off(self) -> None:
396  """Turn the media player off."""
397  self._update_receiver_update_receiver("power", "standby")
398 
399  async def async_set_volume_level(self, volume: float) -> None:
400  """Set volume level, range 0..1.
401 
402  However full volume on the amp is usually far too loud so allow the user to
403  specify the upper range with CONF_MAX_VOLUME. We change as per max_volume
404  set by user. This means that if max volume is 80 then full volume in HA
405  will give 80% volume on the receiver. Then we convert that to the correct
406  scale for the receiver.
407  """
408  # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION
409  self._update_receiver_update_receiver(
410  "volume", int(volume * (self._max_volume_max_volume / 100) * self._volume_resolution_volume_resolution)
411  )
412 
413  async def async_volume_up(self) -> None:
414  """Increase volume by 1 step."""
415  self._update_receiver_update_receiver("volume", "level-up")
416 
417  async def async_volume_down(self) -> None:
418  """Decrease volume by 1 step."""
419  self._update_receiver_update_receiver("volume", "level-down")
420 
421  async def async_mute_volume(self, mute: bool) -> None:
422  """Mute the volume."""
423  self._update_receiver_update_receiver(
424  "audio-muting" if self._zone_zone == "main" else "muting",
425  "on" if mute else "off",
426  )
427 
428  async def async_select_source(self, source: str) -> None:
429  """Select input source."""
430  if self.source_listsource_list and source in self.source_listsource_list:
431  source_lib = self._lib_mapping_lib_mapping[self._reverse_name_mapping_reverse_name_mapping[source]]
432  if isinstance(source_lib, str):
433  source_lib_single = source_lib
434  else:
435  source_lib_single = source_lib[0]
436  self._update_receiver_update_receiver(
437  "input-selector" if self._zone_zone == "main" else "selector", source_lib_single
438  )
439 
440  async def async_select_output(self, hdmi_output: str) -> None:
441  """Set hdmi-out."""
442  self._update_receiver_update_receiver("hdmi-output-selector", hdmi_output)
443 
444  async def async_play_media(
445  self, media_type: MediaType | str, media_id: str, **kwargs: Any
446  ) -> None:
447  """Play radio station by preset number."""
448  if self.sourcesource is not None:
449  source = self._reverse_name_mapping_reverse_name_mapping[self.sourcesource]
450  if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES:
451  self._update_receiver_update_receiver("preset", media_id)
452 
453  @callback
454  def backfill_state(self) -> None:
455  """Get the receiver to send all the info we care about.
456 
457  Usually run only on connect, as we can otherwise rely on the
458  receiver to keep us informed of changes.
459  """
460  self._query_receiver_query_receiver("power")
461  self._query_receiver_query_receiver("volume")
462  self._query_receiver_query_receiver("preset")
463  if self._zone_zone == "main":
464  self._query_receiver_query_receiver("hdmi-output-selector")
465  self._query_receiver_query_receiver("audio-muting")
466  self._query_receiver_query_receiver("input-selector")
467  self._query_receiver_query_receiver("listening-mode")
468  self._query_receiver_query_receiver("audio-information")
469  self._query_receiver_query_receiver("video-information")
470  else:
471  self._query_receiver_query_receiver("muting")
472  self._query_receiver_query_receiver("selector")
473 
474  @callback
475  def process_update(self, update: tuple[str, str, Any]) -> None:
476  """Store relevant updates so they can be queried later."""
477  zone, command, value = update
478  if zone != self._zone_zone:
479  return
480 
481  if command in ["system-power", "power"]:
482  if value == "on":
483  self._attr_state_attr_state = MediaPlayerState.ON
484  else:
485  self._attr_state_attr_state = MediaPlayerState.OFF
486  self._attr_extra_state_attributes_attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None)
487  self._attr_extra_state_attributes_attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None)
488  self._attr_extra_state_attributes_attr_extra_state_attributes.pop(ATTR_PRESET, None)
489  self._attr_extra_state_attributes_attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None)
490  elif command in ["volume", "master-volume"] and value != "N/A":
491  self._supports_volume_supports_volume = True
492  # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100))
493  volume_level: float = value / (
494  self._volume_resolution_volume_resolution * self._max_volume_max_volume / 100
495  )
496  self._attr_volume_level_attr_volume_level = min(1, volume_level)
497  elif command in ["muting", "audio-muting"]:
498  self._attr_is_volume_muted_attr_is_volume_muted = bool(value == "on")
499  elif command in ["selector", "input-selector"]:
500  self._parse_source_parse_source(value)
501  self._query_av_info_delayed_query_av_info_delayed()
502  elif command == "hdmi-output-selector":
503  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(value)
504  elif command == "preset":
505  if self.sourcesource is not None and self.sourcesource.lower() == "radio":
506  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_PRESET] = value
507  elif ATTR_PRESET in self._attr_extra_state_attributes_attr_extra_state_attributes:
508  del self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_PRESET]
509  elif command == "audio-information":
510  self._supports_audio_info_supports_audio_info = True
511  self._parse_audio_information_parse_audio_information(value)
512  elif command == "video-information":
513  self._supports_video_info_supports_video_info = True
514  self._parse_video_information_parse_video_information(value)
515  elif command == "fl-display-information":
516  self._query_av_info_delayed_query_av_info_delayed()
517 
518  self.async_write_ha_stateasync_write_ha_state()
519 
520  @callback
521  def _parse_source(self, source_lib: InputLibValue) -> None:
522  source = self._reverse_lib_mapping_reverse_lib_mapping[source_lib]
523  if source in self._name_mapping_name_mapping:
524  self._attr_source_attr_source = self._name_mapping_name_mapping[source]
525  return
526 
527  source_meaning = source.value_meaning
528  _LOGGER.error(
529  'Input source "%s" not in source list: %s', source_meaning, self.entity_identity_id
530  )
531  self._attr_source_attr_source = source_meaning
532 
533  @callback
535  self, audio_information: tuple[str] | Literal["N/A"]
536  ) -> None:
537  # If audio information is not available, N/A is returned,
538  # so only update the audio information, when it is not N/A.
539  if audio_information == "N/A":
540  self._attr_extra_state_attributes_attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None)
541  return
542 
543  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = {
544  name: value
545  for name, value in zip(
546  AUDIO_INFORMATION_MAPPING, audio_information, strict=False
547  )
548  if len(value) > 0
549  }
550 
551  @callback
553  self, video_information: tuple[str] | Literal["N/A"]
554  ) -> None:
555  # If video information is not available, N/A is returned,
556  # so only update the video information, when it is not N/A.
557  if video_information == "N/A":
558  self._attr_extra_state_attributes_attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None)
559  return
560 
561  self._attr_extra_state_attributes_attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = {
562  name: value
563  for name, value in zip(
564  VIDEO_INFORMATION_MAPPING, video_information, strict=False
565  )
566  if len(value) > 0
567  }
568 
569  def _query_av_info_delayed(self) -> None:
570  if self._zone_zone == "main" and not self._query_timer_query_timer:
571 
572  @callback
573  def _query_av_info() -> None:
574  if self._supports_audio_info_supports_audio_info:
575  self._query_receiver_query_receiver("audio-information")
576  if self._supports_video_info_supports_video_info:
577  self._query_receiver_query_receiver("video-information")
578  self._query_timer_query_timer = None
579 
580  self._query_timer_query_timer = self.hasshass.loop.call_later(
581  AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME, _query_av_info
582  )
None process_update(self, tuple[str, str, Any] update)
None _parse_video_information(self, tuple[str]|Literal["N/A"] video_information)
None __init__(self, Receiver receiver, str zone, *VolumeResolution volume_resolution, float max_volume, dict[InputSource, str] sources)
None _parse_source(self, InputLibValue source_lib)
None async_play_media(self, MediaType|str media_type, str media_id, **Any kwargs)
None _parse_audio_information(self, tuple[str]|Literal["N/A"] audio_information)
None _update_receiver(self, str propname, Any value)
None async_discover(DiscoveryInfo discovery_info)
Definition: sensor.py:217
None async_setup_entry(HomeAssistant hass, OnkyoConfigEntry entry, AddEntitiesCallback async_add_entities)
dict[InputSource, InputLibValue] _input_lib_cmds(str zone)
None async_setup_platform(HomeAssistant hass, ConfigType config, AddEntitiesCallback async_add_entities, DiscoveryInfoType|None discovery_info=None)
None async_create_issue(HomeAssistant hass, str entry_id)
Definition: repairs.py:69