"""Support for Onkyo Receivers.""" from __future__ import annotations import logging from typing import Any import eiscp from eiscp import eISCP import voluptuous as vol from homeassistant.components.media_player import ( DOMAIN, PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) CONF_SOURCES = "sources" CONF_MAX_VOLUME = "max_volume" CONF_RECEIVER_MAX_VOLUME = "receiver_max_volume" DEFAULT_NAME = "Onkyo Receiver" SUPPORTED_MAX_VOLUME = 100 DEFAULT_RECEIVER_MAX_VOLUME = 80 SUPPORT_ONKYO_WO_VOLUME = ( MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA ) SUPPORT_ONKYO = ( SUPPORT_ONKYO_WO_VOLUME | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.VOLUME_STEP ) KNOWN_HOSTS: list[str] = [] DEFAULT_SOURCES = { "tv": "TV", "bd": "Bluray", "game": "Game", "aux1": "Aux1", "video1": "Video 1", "video2": "Video 2", "video3": "Video 3", "video4": "Video 4", "video5": "Video 5", "video6": "Video 6", "video7": "Video 7", "fm": "Radio", } DEFAULT_PLAYABLE_SOURCES = ("fm", "am", "tuner") PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_MAX_VOLUME, default=SUPPORTED_MAX_VOLUME): vol.All( vol.Coerce(int), vol.Range(min=1, max=100) ), vol.Optional( CONF_RECEIVER_MAX_VOLUME, default=DEFAULT_RECEIVER_MAX_VOLUME ): cv.positive_int, vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): {cv.string: cv.string}, } ) TIMEOUT_MESSAGE = "Timeout waiting for response." ATTR_HDMI_OUTPUT = "hdmi_output" ATTR_PRESET = "preset" ATTR_AUDIO_INFORMATION = "audio_information" ATTR_VIDEO_INFORMATION = "video_information" ATTR_VIDEO_OUT = "video_out" ACCEPTED_VALUES = [ "no", "analog", "yes", "out", "out-sub", "sub", "hdbaset", "both", "up", ] ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( { vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES), } ) SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" def _parse_onkyo_payload(payload): """Parse a payload returned from the eiscp library.""" if isinstance(payload, bool): # command not supported by the device return False if len(payload) < 2: # no value return None if isinstance(payload[1], str): return payload[1].split(",") return payload[1] def _tuple_get(tup, index, default=None): """Return a tuple item at index or a default value if it doesn't exist.""" return (tup[index : index + 1] or [default])[0] def determine_zones(receiver): """Determine what zones are available for the receiver.""" out = {"zone2": False, "zone3": False} try: _LOGGER.debug("Checking for zone 2 capability") response = receiver.raw("ZPWQSTN") if response != "ZPWN/A": # Zone 2 Available out["zone2"] = True else: _LOGGER.debug("Zone 2 not available") except ValueError as error: if str(error) != TIMEOUT_MESSAGE: raise error _LOGGER.debug("Zone 2 timed out, assuming no functionality") try: _LOGGER.debug("Checking for zone 3 capability") response = receiver.raw("PW3QSTN") if response != "PW3N/A": out["zone3"] = True else: _LOGGER.debug("Zone 3 not available") except ValueError as error: if str(error) != TIMEOUT_MESSAGE: raise error _LOGGER.debug("Zone 3 timed out, assuming no functionality") except AssertionError: _LOGGER.error("Zone 3 detection failed") return out def setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Onkyo platform.""" hosts: list[OnkyoDevice] = [] def service_handle(service: ServiceCall) -> None: """Handle for services.""" entity_ids = service.data[ATTR_ENTITY_ID] devices = [d for d in hosts if d.entity_id in entity_ids] for device in devices: if service.service == SERVICE_SELECT_HDMI_OUTPUT: device.select_output(service.data[ATTR_HDMI_OUTPUT]) hass.services.register( DOMAIN, SERVICE_SELECT_HDMI_OUTPUT, service_handle, schema=ONKYO_SELECT_OUTPUT_SCHEMA, ) if CONF_HOST in config and (host := config[CONF_HOST]) not in KNOWN_HOSTS: try: receiver = eiscp.eISCP(host) hosts.append( OnkyoDevice( receiver, config.get(CONF_SOURCES), name=config.get(CONF_NAME), max_volume=config.get(CONF_MAX_VOLUME), receiver_max_volume=config.get(CONF_RECEIVER_MAX_VOLUME), ) ) KNOWN_HOSTS.append(host) zones = determine_zones(receiver) # Add Zone2 if available if zones["zone2"]: _LOGGER.debug("Setting up zone 2") hosts.append( OnkyoDeviceZone( "2", receiver, config.get(CONF_SOURCES), name=f"{config[CONF_NAME]} Zone 2", max_volume=config.get(CONF_MAX_VOLUME), receiver_max_volume=config.get(CONF_RECEIVER_MAX_VOLUME), ) ) # Add Zone3 if available if zones["zone3"]: _LOGGER.debug("Setting up zone 3") hosts.append( OnkyoDeviceZone( "3", receiver, config.get(CONF_SOURCES), name=f"{config[CONF_NAME]} Zone 3", max_volume=config.get(CONF_MAX_VOLUME), receiver_max_volume=config.get(CONF_RECEIVER_MAX_VOLUME), ) ) except OSError: _LOGGER.error("Unable to connect to receiver at %s", host) else: for receiver in eISCP.discover(): if receiver.host not in KNOWN_HOSTS: hosts.append(OnkyoDevice(receiver, config.get(CONF_SOURCES))) KNOWN_HOSTS.append(receiver.host) add_entities(hosts, True) class OnkyoDevice(MediaPlayerEntity): """Representation of an Onkyo device.""" _attr_supported_features = SUPPORT_ONKYO def __init__( self, receiver, sources, name=None, max_volume=SUPPORTED_MAX_VOLUME, receiver_max_volume=DEFAULT_RECEIVER_MAX_VOLUME, ): """Initialize the Onkyo Receiver.""" self._receiver = receiver self._attr_is_volume_muted = False self._attr_volume_level = 0 self._attr_state = MediaPlayerState.OFF if name: # not discovered self._attr_name = name else: # discovered self._attr_unique_id = ( f"{receiver.info['model_name']}_{receiver.info['identifier']}" ) self._attr_name = self._attr_unique_id self._max_volume = max_volume self._receiver_max_volume = receiver_max_volume self._attr_source_list = list(sources.values()) self._source_mapping = sources self._reverse_mapping = {value: key for key, value in sources.items()} self._attr_extra_state_attributes = {} self._hdmi_out_supported = True self._audio_info_supported = True self._video_info_supported = True def command(self, command): """Run an eiscp command and catch connection errors.""" try: result = self._receiver.command(command) except (ValueError, OSError, AttributeError, AssertionError): if self._receiver.command_socket: self._receiver.command_socket = None _LOGGER.debug("Resetting connection to %s", self.name) else: _LOGGER.info("%s is disconnected. Attempting to reconnect", self.name) return False _LOGGER.debug("Result for %s: %s", command, result) return result def update(self) -> None: """Get the latest state from the device.""" status = self.command("system-power query") if not status: return if status[1] == "on": self._attr_state = MediaPlayerState.ON else: self._attr_state = MediaPlayerState.OFF self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) self._attr_extra_state_attributes.pop(ATTR_PRESET, None) self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) return volume_raw = self.command("volume query") mute_raw = self.command("audio-muting query") current_source_raw = self.command("input-selector query") # If the following command is sent to a device with only one HDMI out, # the display shows 'Not Available'. # We avoid this by checking if HDMI out is supported if self._hdmi_out_supported: hdmi_out_raw = self.command("hdmi-output-selector query") else: hdmi_out_raw = [] preset_raw = self.command("preset query") if self._audio_info_supported: audio_information_raw = self.command("audio-information query") self._parse_audio_information(audio_information_raw) if self._video_info_supported: video_information_raw = self.command("video-information query") self._parse_video_information(video_information_raw) if not (volume_raw and mute_raw and current_source_raw): return sources = _parse_onkyo_payload(current_source_raw) for source in sources: if source in self._source_mapping: self._attr_source = self._source_mapping[source] break self._attr_source = "_".join(sources) if preset_raw and self.source and self.source.lower() == "radio": self._attr_extra_state_attributes[ATTR_PRESET] = preset_raw[1] elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] self._attr_is_volume_muted = bool(mute_raw[1] == "on") # AMP_VOL/MAX_RECEIVER_VOL*(MAX_VOL/100) self._attr_volume_level = volume_raw[1] / ( self._receiver_max_volume * self._max_volume / 100 ) if not hdmi_out_raw: return self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(hdmi_out_raw[1]) if hdmi_out_raw[1] == "N/A": self._hdmi_out_supported = False def turn_off(self) -> None: """Turn the media player off.""" self.command("system-power standby") def set_volume_level(self, volume: float) -> None: """Set volume level, input is range 0..1. However full volume on the amp is usually far too loud so allow the user to specify the upper range with CONF_MAX_VOLUME. we change as per max_volume set by user. This means that if max volume is 80 then full volume in HA will give 80% volume on the receiver. Then we convert that to the correct scale for the receiver. """ # HA_VOL * (MAX VOL / 100) * MAX_RECEIVER_VOL self.command( "volume" f" {int(volume * (self._max_volume / 100) * self._receiver_max_volume)}" ) def volume_up(self) -> None: """Increase volume by 1 step.""" self.command("volume level-up") def volume_down(self) -> None: """Decrease volume by 1 step.""" self.command("volume level-down") def mute_volume(self, mute: bool) -> None: """Mute (true) or unmute (false) media player.""" if mute: self.command("audio-muting on") else: self.command("audio-muting off") def turn_on(self) -> None: """Turn the media player on.""" self.command("system-power on") def select_source(self, source: str) -> None: """Set the input source.""" if self.source_list and source in self.source_list: source = self._reverse_mapping[source] self.command(f"input-selector {source}") def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: """Play radio station by preset number.""" source = self._reverse_mapping[self._attr_source] if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES: self.command(f"preset {media_id}") def select_output(self, output): """Set hdmi-out.""" self.command(f"hdmi-output-selector={output}") def _parse_audio_information(self, audio_information_raw): values = _parse_onkyo_payload(audio_information_raw) if values is False: self._audio_info_supported = False return if values: info = { "format": _tuple_get(values, 1), "input_frequency": _tuple_get(values, 2), "input_channels": _tuple_get(values, 3), "listening_mode": _tuple_get(values, 4), "output_channels": _tuple_get(values, 5), "output_frequency": _tuple_get(values, 6), } self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = info else: self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None) def _parse_video_information(self, video_information_raw): values = _parse_onkyo_payload(video_information_raw) if values is False: self._video_info_supported = False return if values: info = { "input_resolution": _tuple_get(values, 1), "input_color_schema": _tuple_get(values, 2), "input_color_depth": _tuple_get(values, 3), "output_resolution": _tuple_get(values, 5), "output_color_schema": _tuple_get(values, 6), "output_color_depth": _tuple_get(values, 7), "picture_mode": _tuple_get(values, 8), } self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = info else: self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None) class OnkyoDeviceZone(OnkyoDevice): """Representation of an Onkyo device's extra zone.""" def __init__( self, zone, receiver, sources, name=None, max_volume=SUPPORTED_MAX_VOLUME, receiver_max_volume=DEFAULT_RECEIVER_MAX_VOLUME, ): """Initialize the Zone with the zone identifier.""" self._zone = zone self._supports_volume = True super().__init__(receiver, sources, name, max_volume, receiver_max_volume) def update(self) -> None: """Get the latest state from the device.""" status = self.command(f"zone{self._zone}.power=query") if not status: return if status[1] == "on": self._attr_state = MediaPlayerState.ON else: self._attr_state = MediaPlayerState.OFF return volume_raw = self.command(f"zone{self._zone}.volume=query") mute_raw = self.command(f"zone{self._zone}.muting=query") current_source_raw = self.command(f"zone{self._zone}.selector=query") preset_raw = self.command(f"zone{self._zone}.preset=query") # If we received a source value, but not a volume value # it's likely this zone permanently does not support volume. if current_source_raw and not volume_raw: self._supports_volume = False if not (volume_raw and mute_raw and current_source_raw): return # It's possible for some players to have zones set to HDMI with # no sound control. In this case, the string `N/A` is returned. self._supports_volume = isinstance(volume_raw[1], (float, int)) # eiscp can return string or tuple. Make everything tuples. if isinstance(current_source_raw[1], str): current_source_tuples = (current_source_raw[0], (current_source_raw[1],)) else: current_source_tuples = current_source_raw for source in current_source_tuples[1]: if source in self._source_mapping: self._attr_source = self._source_mapping[source] break self._attr_source = "_".join(current_source_tuples[1]) self._attr_is_volume_muted = bool(mute_raw[1] == "on") if preset_raw and self.source and self.source.lower() == "radio": self._attr_extra_state_attributes[ATTR_PRESET] = preset_raw[1] elif ATTR_PRESET in self._attr_extra_state_attributes: del self._attr_extra_state_attributes[ATTR_PRESET] if self._supports_volume: # AMP_VOL/MAX_RECEIVER_VOL*(MAX_VOL/100) self._attr_volume_level = ( volume_raw[1] / self._receiver_max_volume * (self._max_volume / 100) ) @property def supported_features(self) -> MediaPlayerEntityFeature: """Return media player features that are supported.""" if self._supports_volume: return SUPPORT_ONKYO return SUPPORT_ONKYO_WO_VOLUME def turn_off(self) -> None: """Turn the media player off.""" self.command(f"zone{self._zone}.power=standby") def set_volume_level(self, volume: float) -> None: """Set volume level, input is range 0..1. However full volume on the amp is usually far too loud so allow the user to specify the upper range with CONF_MAX_VOLUME. we change as per max_volume set by user. This means that if max volume is 80 then full volume in HA will give 80% volume on the receiver. Then we convert that to the correct scale for the receiver. """ # HA_VOL * (MAX VOL / 100) * MAX_RECEIVER_VOL self.command( f"zone{self._zone}.volume={int(volume * (self._max_volume / 100) * self._receiver_max_volume)}" ) def volume_up(self) -> None: """Increase volume by 1 step.""" self.command(f"zone{self._zone}.volume=level-up") def volume_down(self) -> None: """Decrease volume by 1 step.""" self.command(f"zone{self._zone}.volume=level-down") def mute_volume(self, mute: bool) -> None: """Mute (true) or unmute (false) media player.""" if mute: self.command(f"zone{self._zone}.muting=on") else: self.command(f"zone{self._zone}.muting=off") def turn_on(self) -> None: """Turn the media player on.""" self.command(f"zone{self._zone}.power=on") def select_source(self, source: str) -> None: """Set the input source.""" if self.source_list and source in self.source_list: source = self._reverse_mapping[source] self.command(f"zone{self._zone}.selector={source}")