Add sound mode support to Onkyo (#133531)

This commit is contained in:
Artur Pragacz 2025-02-25 17:21:05 +01:00 committed by GitHub
parent 2bba185e4c
commit 38cc26485a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 447 additions and 134 deletions

View File

@ -1,6 +1,7 @@
"""The onkyo component."""
from dataclasses import dataclass
import logging
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
@ -9,10 +10,18 @@ from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, OPTION_INPUT_SOURCES, InputSource
from .const import (
DOMAIN,
OPTION_INPUT_SOURCES,
OPTION_LISTENING_MODES,
InputSource,
ListeningMode,
)
from .receiver import Receiver, async_interview
from .services import DATA_MP_ENTITIES, async_register_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS = [Platform.MEDIA_PLAYER]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
@ -24,6 +33,7 @@ class OnkyoData:
receiver: Receiver
sources: dict[InputSource, str]
sound_modes: dict[ListeningMode, str]
type OnkyoConfigEntry = ConfigEntry[OnkyoData]
@ -50,7 +60,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES]
sources = {InputSource(k): v for k, v in sources_store.items()}
entry.runtime_data = OnkyoData(receiver, sources)
sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {})
sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()}
entry.runtime_data = OnkyoData(receiver, sources, sound_modes)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@ -1,5 +1,6 @@
"""Config flow for Onkyo."""
from collections.abc import Mapping
import logging
from typing import Any
@ -33,12 +34,14 @@ from .const import (
CONF_SOURCES,
DOMAIN,
OPTION_INPUT_SOURCES,
OPTION_LISTENING_MODES,
OPTION_MAX_VOLUME,
OPTION_MAX_VOLUME_DEFAULT,
OPTION_VOLUME_RESOLUTION,
OPTION_VOLUME_RESOLUTION_DEFAULT,
VOLUME_RESOLUTION_ALLOWED,
InputSource,
ListeningMode,
)
from .receiver import ReceiverInfo, async_discover, async_interview
@ -46,9 +49,14 @@ _LOGGER = logging.getLogger(__name__)
CONF_DEVICE = "device"
INPUT_SOURCES_ALL_MEANINGS = [
input_source.value_meaning for input_source in InputSource
]
INPUT_SOURCES_DEFAULT: dict[str, str] = {}
LISTENING_MODES_DEFAULT: dict[str, str] = {}
INPUT_SOURCES_ALL_MEANINGS = {
input_source.value_meaning: input_source for input_source in InputSource
}
LISTENING_MODES_ALL_MEANINGS = {
listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode
}
STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
STEP_RECONFIGURE_SCHEMA = vol.Schema(
{
@ -59,7 +67,14 @@ STEP_CONFIGURE_SCHEMA = STEP_RECONFIGURE_SCHEMA.extend(
{
vol.Required(OPTION_INPUT_SOURCES): SelectSelector(
SelectSelectorConfig(
options=INPUT_SOURCES_ALL_MEANINGS,
options=list(INPUT_SOURCES_ALL_MEANINGS),
multiple=True,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(OPTION_LISTENING_MODES): SelectSelector(
SelectSelectorConfig(
options=list(LISTENING_MODES_ALL_MEANINGS),
multiple=True,
mode=SelectSelectorMode.DROPDOWN,
)
@ -238,9 +253,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
CONF_HOST: self._receiver_info.host,
},
options={
**entry_options,
OPTION_VOLUME_RESOLUTION: volume_resolution,
OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME],
OPTION_INPUT_SOURCES: entry_options[OPTION_INPUT_SOURCES],
},
)
@ -250,12 +264,24 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES]
if not input_source_meanings:
errors[OPTION_INPUT_SOURCES] = "empty_input_source_list"
else:
listening_modes: list[str] = user_input[OPTION_LISTENING_MODES]
if not listening_modes:
errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list"
if not errors:
input_sources_store: dict[str, str] = {}
for input_source_meaning in input_source_meanings:
input_source = InputSource.from_meaning(input_source_meaning)
input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning]
input_sources_store[input_source.value] = input_source_meaning
listening_modes_store: dict[str, str] = {}
for listening_mode_meaning in listening_modes:
listening_mode = LISTENING_MODES_ALL_MEANINGS[
listening_mode_meaning
]
listening_modes_store[listening_mode.value] = listening_mode_meaning
result = self.async_create_entry(
title=self._receiver_info.model_name,
data={
@ -265,6 +291,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
OPTION_VOLUME_RESOLUTION: volume_resolution,
OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT,
OPTION_INPUT_SOURCES: input_sources_store,
OPTION_LISTENING_MODES: listening_modes_store,
},
)
@ -278,16 +305,13 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
if reconfigure_entry is None:
suggested_values = {
OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT,
OPTION_INPUT_SOURCES: [],
OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT,
OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT,
}
else:
entry_options = reconfigure_entry.options
suggested_values = {
OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION],
OPTION_INPUT_SOURCES: [
InputSource(input_source).value_meaning
for input_source in entry_options[OPTION_INPUT_SOURCES]
],
}
return self.async_show_form(
@ -356,6 +380,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
OPTION_VOLUME_RESOLUTION: volume_resolution,
OPTION_MAX_VOLUME: max_volume,
OPTION_INPUT_SOURCES: sources_store,
OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT,
},
)
@ -373,7 +398,14 @@ OPTIONS_STEP_INIT_SCHEMA = vol.Schema(
),
vol.Required(OPTION_INPUT_SOURCES): SelectSelector(
SelectSelectorConfig(
options=INPUT_SOURCES_ALL_MEANINGS,
options=list(INPUT_SOURCES_ALL_MEANINGS),
multiple=True,
mode=SelectSelectorMode.DROPDOWN,
)
),
vol.Required(OPTION_LISTENING_MODES): SelectSelector(
SelectSelectorConfig(
options=list(LISTENING_MODES_ALL_MEANINGS),
multiple=True,
mode=SelectSelectorMode.DROPDOWN,
)
@ -387,6 +419,7 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
_data: dict[str, Any]
_input_sources: dict[InputSource, str]
_listening_modes: dict[ListeningMode, str]
async def async_step_init(
self, user_input: dict[str, Any] | None = None
@ -394,20 +427,40 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
"""Manage the options."""
errors = {}
entry_options = self.config_entry.options
entry_options: Mapping[str, Any] = self.config_entry.options
entry_options = {
OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT,
**entry_options,
}
if user_input is not None:
self._input_sources = {}
for input_source_meaning in user_input[OPTION_INPUT_SOURCES]:
input_source = InputSource.from_meaning(input_source_meaning)
input_source_name = entry_options[OPTION_INPUT_SOURCES].get(
input_source.value, input_source_meaning
)
self._input_sources[input_source] = input_source_name
if not self._input_sources:
input_source_meanings: list[str] = user_input[OPTION_INPUT_SOURCES]
if not input_source_meanings:
errors[OPTION_INPUT_SOURCES] = "empty_input_source_list"
else:
listening_mode_meanings: list[str] = user_input[OPTION_LISTENING_MODES]
if not listening_mode_meanings:
errors[OPTION_LISTENING_MODES] = "empty_listening_mode_list"
if not errors:
self._input_sources = {}
for input_source_meaning in input_source_meanings:
input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning]
input_source_name = entry_options[OPTION_INPUT_SOURCES].get(
input_source.value, input_source_meaning
)
self._input_sources[input_source] = input_source_name
self._listening_modes = {}
for listening_mode_meaning in listening_mode_meanings:
listening_mode = LISTENING_MODES_ALL_MEANINGS[
listening_mode_meaning
]
listening_mode_name = entry_options[OPTION_LISTENING_MODES].get(
listening_mode.value, listening_mode_meaning
)
self._listening_modes[listening_mode] = listening_mode_name
self._data = {
OPTION_VOLUME_RESOLUTION: entry_options[OPTION_VOLUME_RESOLUTION],
OPTION_MAX_VOLUME: user_input[OPTION_MAX_VOLUME],
@ -423,6 +476,10 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
InputSource(input_source).value_meaning
for input_source in entry_options[OPTION_INPUT_SOURCES]
],
OPTION_LISTENING_MODES: [
ListeningMode(listening_mode).value_meaning
for listening_mode in entry_options[OPTION_LISTENING_MODES]
],
}
return self.async_show_form(
@ -440,28 +497,48 @@ class OnkyoOptionsFlowHandler(OptionsFlow):
if user_input is not None:
input_sources_store: dict[str, str] = {}
for input_source_meaning, input_source_name in user_input[
"input_sources"
OPTION_INPUT_SOURCES
].items():
input_source = InputSource.from_meaning(input_source_meaning)
input_source = INPUT_SOURCES_ALL_MEANINGS[input_source_meaning]
input_sources_store[input_source.value] = input_source_name
listening_modes_store: dict[str, str] = {}
for listening_mode_meaning, listening_mode_name in user_input[
OPTION_LISTENING_MODES
].items():
listening_mode = LISTENING_MODES_ALL_MEANINGS[listening_mode_meaning]
listening_modes_store[listening_mode.value] = listening_mode_name
return self.async_create_entry(
data={
**self._data,
OPTION_INPUT_SOURCES: input_sources_store,
OPTION_LISTENING_MODES: listening_modes_store,
}
)
schema_dict: dict[Any, Selector] = {}
input_sources_schema_dict: dict[Any, Selector] = {}
for input_source, input_source_name in self._input_sources.items():
schema_dict[
input_sources_schema_dict[
vol.Required(input_source.value_meaning, default=input_source_name)
] = TextSelector()
listening_modes_schema_dict: dict[Any, Selector] = {}
for listening_mode, listening_mode_name in self._listening_modes.items():
listening_modes_schema_dict[
vol.Required(listening_mode.value_meaning, default=listening_mode_name)
] = TextSelector()
return self.async_show_form(
step_id="names",
data_schema=vol.Schema(
{vol.Required("input_sources"): section(vol.Schema(schema_dict))}
{
vol.Required(OPTION_INPUT_SOURCES): section(
vol.Schema(input_sources_schema_dict)
),
vol.Required(OPTION_LISTENING_MODES): section(
vol.Schema(listening_modes_schema_dict)
),
}
),
)

View File

@ -2,7 +2,7 @@
from enum import Enum
import typing
from typing import ClassVar, Literal, Self
from typing import Literal, Self
import pyeiscp
@ -24,7 +24,27 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args(
OPTION_MAX_VOLUME = "max_volume"
OPTION_MAX_VOLUME_DEFAULT = 100.0
class EnumWithMeaning(Enum):
"""Enum with meaning."""
value_meaning: str
def __new__(cls, value: str) -> Self:
"""Create enum."""
obj = object.__new__(cls)
obj._value_ = value
obj.value_meaning = cls._get_meanings()[value]
return obj
@staticmethod
def _get_meanings() -> dict[str, str]:
raise NotImplementedError
OPTION_INPUT_SOURCES = "input_sources"
OPTION_LISTENING_MODES = "listening_modes"
_INPUT_SOURCE_MEANINGS = {
"00": "VIDEO1 ··· VCR/DVR ··· STB/DVR",
@ -71,7 +91,7 @@ _INPUT_SOURCE_MEANINGS = {
}
class InputSource(Enum):
class InputSource(EnumWithMeaning):
"""Receiver input source."""
DVR = "00"
@ -116,24 +136,100 @@ class InputSource(Enum):
HDMI_7 = "57"
MAIN_SOURCE = "80"
__meaning_mapping: ClassVar[dict[str, Self]] = {} # type: ignore[misc]
@staticmethod
def _get_meanings() -> dict[str, str]:
return _INPUT_SOURCE_MEANINGS
value_meaning: str
def __new__(cls, value: str) -> Self:
"""Create InputSource enum."""
obj = object.__new__(cls)
obj._value_ = value
obj.value_meaning = _INPUT_SOURCE_MEANINGS[value]
_LISTENING_MODE_MEANINGS = {
"00": "STEREO",
"01": "DIRECT",
"02": "SURROUND",
"03": "FILM ··· GAME RPG ··· ADVANCED GAME",
"04": "THX",
"05": "ACTION ··· GAME ACTION",
"06": "MUSICAL ··· GAME ROCK ··· ROCK/POP",
"07": "MONO MOVIE",
"08": "ORCHESTRA ··· CLASSICAL",
"09": "UNPLUGGED",
"0A": "STUDIO MIX ··· ENTERTAINMENT SHOW",
"0B": "TV LOGIC ··· DRAMA",
"0C": "ALL CH STEREO ··· EXTENDED STEREO",
"0D": "THEATER DIMENSIONAL ··· FRONT STAGE SURROUND",
"0E": "ENHANCED 7/ENHANCE ··· GAME SPORTS ··· SPORTS",
"0F": "MONO",
"11": "PURE AUDIO ··· PURE DIRECT",
"12": "MULTIPLEX",
"13": "FULL MONO ··· MONO MUSIC",
"14": "DOLBY VIRTUAL/SURROUND ENHANCER",
"15": "DTS SURROUND SENSATION",
"16": "AUDYSSEY DSX",
"17": "DTS VIRTUAL:X",
"1F": "WHOLE HOUSE MODE ··· MULTI ZONE MUSIC",
"23": "STAGE (JAPAN GENRE CONTROL)",
"25": "ACTION (JAPAN GENRE CONTROL)",
"26": "MUSIC (JAPAN GENRE CONTROL)",
"2E": "SPORTS (JAPAN GENRE CONTROL)",
"40": "STRAIGHT DECODE ··· 5.1 CH SURROUND",
"41": "DOLBY EX/DTS ES",
"42": "THX CINEMA",
"43": "THX SURROUND EX",
"44": "THX MUSIC",
"45": "THX GAMES",
"50": "THX U(2)/S(2)/I/S CINEMA",
"51": "THX U(2)/S(2)/I/S MUSIC",
"52": "THX U(2)/S(2)/I/S GAMES",
"80": "DOLBY ATMOS/DOLBY SURROUND ··· PLII/PLIIx MOVIE",
"81": "PLII/PLIIx MUSIC",
"82": "DTS:X/NEURAL:X ··· NEO:6/NEO:X CINEMA",
"83": "NEO:6/NEO:X MUSIC",
"84": "DOLBY SURROUND THX CINEMA ··· PLII/PLIIx THX CINEMA",
"85": "DTS NEURAL:X THX CINEMA ··· NEO:6/NEO:X THX CINEMA",
"86": "PLII/PLIIx GAME",
"87": "NEURAL SURR",
"88": "NEURAL THX/NEURAL SURROUND",
"89": "DOLBY SURROUND THX GAMES ··· PLII/PLIIx THX GAMES",
"8A": "DTS NEURAL:X THX GAMES ··· NEO:6/NEO:X THX GAMES",
"8B": "DOLBY SURROUND THX MUSIC ··· PLII/PLIIx THX MUSIC",
"8C": "DTS NEURAL:X THX MUSIC ··· NEO:6/NEO:X THX MUSIC",
"8D": "NEURAL THX CINEMA",
"8E": "NEURAL THX MUSIC",
"8F": "NEURAL THX GAMES",
"90": "PLIIz HEIGHT",
"91": "NEO:6 CINEMA DTS SURROUND SENSATION",
"92": "NEO:6 MUSIC DTS SURROUND SENSATION",
"93": "NEURAL DIGITAL MUSIC",
"94": "PLIIz HEIGHT + THX CINEMA",
"95": "PLIIz HEIGHT + THX MUSIC",
"96": "PLIIz HEIGHT + THX GAMES",
"97": "PLIIz HEIGHT + THX U2/S2 CINEMA",
"98": "PLIIz HEIGHT + THX U2/S2 MUSIC",
"99": "PLIIz HEIGHT + THX U2/S2 GAMES",
"9A": "NEO:X GAME",
"A0": "PLIIx/PLII Movie + AUDYSSEY DSX",
"A1": "PLIIx/PLII MUSIC + AUDYSSEY DSX",
"A2": "PLIIx/PLII GAME + AUDYSSEY DSX",
"A3": "NEO:6 CINEMA + AUDYSSEY DSX",
"A4": "NEO:6 MUSIC + AUDYSSEY DSX",
"A5": "NEURAL SURROUND + AUDYSSEY DSX",
"A6": "NEURAL DIGITAL MUSIC + AUDYSSEY DSX",
"A7": "DOLBY EX + AUDYSSEY DSX",
"FF": "AUTO SURROUND",
}
cls.__meaning_mapping[obj.value_meaning] = obj
return obj
class ListeningMode(EnumWithMeaning):
"""Receiver listening mode."""
@classmethod
def from_meaning(cls, meaning: str) -> Self:
"""Get InputSource enum from its meaning."""
return cls.__meaning_mapping[meaning]
_ignore_ = "ListeningMode _k _v _meaning"
ListeningMode = vars()
for _k in _LISTENING_MODE_MEANINGS:
ListeningMode["I" + _k] = _k
@staticmethod
def _get_meanings() -> dict[str, str]:
return _LISTENING_MODE_MEANINGS
ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"}

View File

@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from enum import Enum
from functools import cache
import logging
from typing import Any, Literal
@ -39,6 +40,7 @@ from .const import (
PYEISCP_COMMANDS,
ZONES,
InputSource,
ListeningMode,
VolumeResolution,
)
from .receiver import Receiver, async_discover
@ -63,6 +65,8 @@ CONF_SOURCES_DEFAULT = {
"fm": "Radio",
}
ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo"
PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_HOST): cv.string,
@ -79,23 +83,23 @@ PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend(
}
)
SUPPORT_ONKYO_WO_VOLUME = (
SUPPORTED_FEATURES_BASE = (
MediaPlayerEntityFeature.TURN_ON
| MediaPlayerEntityFeature.TURN_OFF
| MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
)
SUPPORT_ONKYO = (
SUPPORT_ONKYO_WO_VOLUME
| MediaPlayerEntityFeature.VOLUME_SET
SUPPORTED_FEATURES_VOLUME = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
| MediaPlayerEntityFeature.VOLUME_STEP
)
DEFAULT_PLAYABLE_SOURCES = (
InputSource.from_meaning("FM"),
InputSource.from_meaning("AM"),
InputSource.from_meaning("DAB"),
PLAYABLE_SOURCES = (
InputSource.FM,
InputSource.AM,
InputSource.DAB,
)
ATTR_PRESET = "preset"
@ -118,7 +122,6 @@ AUDIO_INFORMATION_MAPPING = [
"auto_phase_control_phase",
"upmix_mode",
]
VIDEO_INFORMATION_MAPPING = [
"video_input_port",
"input_resolution",
@ -131,7 +134,6 @@ VIDEO_INFORMATION_MAPPING = [
"picture_mode",
"input_hdr",
]
ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=onkyo"
type LibValue = str | tuple[str, ...]
@ -139,7 +141,19 @@ type LibValue = str | tuple[str, ...]
def _get_single_lib_value(value: LibValue) -> str:
if isinstance(value, str):
return value
return value[0]
return value[-1]
def _get_lib_mapping[T: Enum](cmds: Any, cls: type[T]) -> dict[T, LibValue]:
result: dict[T, LibValue] = {}
for k, v in cmds["values"].items():
try:
key = cls(k)
except ValueError:
continue
result[key] = v["name"]
return result
@cache
@ -154,15 +168,7 @@ def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]:
case "zone4":
cmds = PYEISCP_COMMANDS["zone4"]["SL4"]
result: dict[InputSource, LibValue] = {}
for k, v in cmds["values"].items():
try:
source = InputSource(k)
except ValueError:
continue
result[source] = v["name"]
return result
return _get_lib_mapping(cmds, InputSource)
@cache
@ -170,6 +176,24 @@ def _rev_input_source_lib_mappings(zone: str) -> dict[LibValue, InputSource]:
return {value: key for key, value in _input_source_lib_mappings(zone).items()}
@cache
def _listening_mode_lib_mappings(zone: str) -> dict[ListeningMode, LibValue]:
match zone:
case "main":
cmds = PYEISCP_COMMANDS["main"]["LMD"]
case "zone2":
cmds = PYEISCP_COMMANDS["zone2"]["LMZ"]
case _:
return {}
return _get_lib_mapping(cmds, ListeningMode)
@cache
def _rev_listening_mode_lib_mappings(zone: str) -> dict[LibValue, ListeningMode]:
return {value: key for key, value in _listening_mode_lib_mappings(zone).items()}
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@ -303,6 +327,7 @@ async def async_setup_entry(
volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION]
max_volume: float = entry.options[OPTION_MAX_VOLUME]
sources = data.sources
sound_modes = data.sound_modes
def connect_callback(receiver: Receiver) -> None:
if not receiver.first_connect:
@ -331,6 +356,7 @@ async def async_setup_entry(
volume_resolution=volume_resolution,
max_volume=max_volume,
sources=sources,
sound_modes=sound_modes,
)
entities[zone] = zone_entity
async_add_entities([zone_entity])
@ -345,6 +371,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
_attr_should_poll = False
_supports_volume: bool = False
_supports_sound_mode: bool = False
_supports_audio_info: bool = False
_supports_video_info: bool = False
_query_timer: asyncio.TimerHandle | None = None
@ -357,6 +384,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
volume_resolution: VolumeResolution,
max_volume: float,
sources: dict[InputSource, str],
sound_modes: dict[ListeningMode, str],
) -> None:
"""Initialize the Onkyo Receiver."""
self._receiver = receiver
@ -381,7 +409,27 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
value: key for key, value in self._source_mapping.items()
}
self._sound_mode_lib_mapping = _listening_mode_lib_mappings(zone)
self._rev_sound_mode_lib_mapping = _rev_listening_mode_lib_mappings(zone)
self._sound_mode_mapping = {
key: value
for key, value in sound_modes.items()
if key in self._sound_mode_lib_mapping
}
self._rev_sound_mode_mapping = {
value: key for key, value in self._sound_mode_mapping.items()
}
self._attr_source_list = list(self._rev_source_mapping)
self._attr_sound_mode_list = list(self._rev_sound_mode_mapping)
self._attr_supported_features = SUPPORTED_FEATURES_BASE
if zone == "main":
self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
self._supports_volume = True
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._supports_sound_mode = True
self._attr_extra_state_attributes = {}
async def async_added_to_hass(self) -> None:
@ -394,13 +442,6 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._query_timer.cancel()
self._query_timer = None
@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
@callback
def _update_receiver(self, propname: str, value: Any) -> None:
"""Update a property in the receiver."""
@ -466,6 +507,24 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
"input-selector" if self._zone == "main" else "selector", source_lib_single
)
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select listening sound mode."""
if not self.sound_mode_list or sound_mode not in self.sound_mode_list:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_sound_mode",
translation_placeholders={
"invalid_sound_mode": sound_mode,
"entity_id": self.entity_id,
},
)
sound_mode_lib = self._sound_mode_lib_mapping[
self._rev_sound_mode_mapping[sound_mode]
]
sound_mode_lib_single = _get_single_lib_value(sound_mode_lib)
self._update_receiver("listening-mode", sound_mode_lib_single)
async def async_select_output(self, hdmi_output: str) -> None:
"""Set hdmi-out."""
self._update_receiver("hdmi-output-selector", hdmi_output)
@ -476,7 +535,7 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
"""Play radio station by preset number."""
if self.source is not None:
source = self._rev_source_mapping[self.source]
if media_type.lower() == "radio" and source in DEFAULT_PLAYABLE_SOURCES:
if media_type.lower() == "radio" and source in PLAYABLE_SOURCES:
self._update_receiver("preset", media_id)
@callback
@ -517,7 +576,9 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._attr_extra_state_attributes.pop(ATTR_PRESET, None)
self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None)
elif command in ["volume", "master-volume"] and value != "N/A":
self._supports_volume = True
if not self._supports_volume:
self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
self._supports_volume = True
# AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100))
volume_level: float = value / (
self._volume_resolution * self._max_volume / 100
@ -535,6 +596,14 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
self._attr_extra_state_attributes[ATTR_PRESET] = value
elif ATTR_PRESET in self._attr_extra_state_attributes:
del self._attr_extra_state_attributes[ATTR_PRESET]
elif command == "listening-mode" and value != "N/A":
if not self._supports_sound_mode:
self._attr_supported_features |= (
MediaPlayerEntityFeature.SELECT_SOUND_MODE
)
self._supports_sound_mode = True
self._parse_sound_mode(value)
self._query_av_info_delayed()
elif command == "audio-information":
self._supports_audio_info = True
self._parse_audio_information(value)
@ -561,6 +630,21 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
)
self._attr_source = source_meaning
@callback
def _parse_sound_mode(self, mode_lib: LibValue) -> None:
sound_mode = self._rev_sound_mode_lib_mapping[mode_lib]
if sound_mode in self._sound_mode_mapping:
self._attr_sound_mode = self._sound_mode_mapping[sound_mode]
return
sound_mode_meaning = sound_mode.value_meaning
_LOGGER.error(
'Listening mode "%s" is invalid for entity: %s',
sound_mode_meaning,
self.entity_id,
)
self._attr_sound_mode = sound_mode_meaning
@callback
def _parse_audio_information(
self, audio_information: tuple[str] | Literal["N/A"]

View File

@ -27,17 +27,20 @@
"description": "Configure {name}",
"data": {
"volume_resolution": "Volume resolution",
"input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]"
"input_sources": "[%key:component::onkyo::options::step::init::data::input_sources%]",
"listening_modes": "[%key:component::onkyo::options::step::init::data::listening_modes%]"
},
"data_description": {
"volume_resolution": "Number of steps it takes for the receiver to go from the lowest to the highest possible volume.",
"input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]"
"input_sources": "[%key:component::onkyo::options::step::init::data_description::input_sources%]",
"listening_modes": "[%key:component::onkyo::options::step::init::data_description::listening_modes%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"empty_input_source_list": "[%key:component::onkyo::options::error::empty_input_source_list%]",
"empty_listening_mode_list": "[%key:component::onkyo::options::error::empty_listening_mode_list%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
@ -53,11 +56,13 @@
"init": {
"data": {
"max_volume": "Maximum volume limit (%)",
"input_sources": "Input sources"
"input_sources": "Input sources",
"listening_modes": "Listening modes"
},
"data_description": {
"max_volume": "Maximum volume limit as a percentage. This will associate Home Assistant's maximum volume to this value on the receiver, i.e., if you set this to 50%, then setting the volume to 100% in Home Assistant will cause the volume on the receiver to be set to 50% of its maximum value.",
"input_sources": "List of input sources supported by the receiver."
"input_sources": "List of input sources supported by the receiver.",
"listening_modes": "List of listening modes supported by the receiver."
}
},
"names": {
@ -65,12 +70,17 @@
"input_sources": {
"name": "Input source names",
"description": "Mappings of receiver's input sources to their names."
},
"listening_modes": {
"name": "Listening mode names",
"description": "Mappings of receiver's listening modes to their names."
}
}
}
},
"error": {
"empty_input_source_list": "Input source list cannot be empty"
"empty_input_source_list": "Input source list cannot be empty",
"empty_listening_mode_list": "Listening mode list cannot be empty"
}
},
"issues": {
@ -84,6 +94,9 @@
}
},
"exceptions": {
"invalid_sound_mode": {
"message": "Cannot select sound mode \"{invalid_sound_mode}\" for entity: {entity_id}."
},
"invalid_source": {
"message": "Cannot select input source \"{invalid_source}\" for entity: {entity_id}."
}

View File

@ -34,8 +34,9 @@ def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry:
data = {CONF_HOST: info.host}
options = {
"volume_resolution": 80,
"input_sources": {"12": "tv"},
"max_volume": 100,
"input_sources": {"12": "tv"},
"listening_modes": {"00": "stereo"},
}
return MockConfigEntry(
@ -52,8 +53,9 @@ def create_empty_config_entry() -> MockConfigEntry:
data = {CONF_HOST: ""}
options = {
"volume_resolution": 80,
"input_sources": {"12": "tv"},
"max_volume": 100,
"input_sources": {"12": "tv"},
"listening_modes": {"00": "stereo"},
}
return MockConfigEntry(

View File

@ -11,7 +11,9 @@ from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow
from homeassistant.components.onkyo.const import (
DOMAIN,
OPTION_INPUT_SOURCES,
OPTION_LISTENING_MODES,
OPTION_MAX_VOLUME,
OPTION_MAX_VOLUME_DEFAULT,
OPTION_VOLUME_RESOLUTION,
)
from homeassistant.config_entries import SOURCE_USER
@ -226,7 +228,11 @@ async def test_ssdp_discovery_success(
select_result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"volume_resolution": 200, "input_sources": ["TV"]},
user_input={
"volume_resolution": 200,
"input_sources": ["TV"],
"listening_modes": ["THX"],
},
)
assert select_result["type"] is FlowResultType.CREATE_ENTRY
@ -349,34 +355,6 @@ async def test_ssdp_discovery_no_host(
assert result["reason"] == "unknown"
async def test_configure_empty_source_list(
hass: HomeAssistant, default_mock_discovery
) -> None:
"""Test receiver configuration with no sources set."""
init_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
form_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
{"next_step_id": "manual"},
)
select_result = await hass.config_entries.flow.async_configure(
form_result["flow_id"],
user_input={CONF_HOST: "sample-host-name"},
)
configure_result = await hass.config_entries.flow.async_configure(
select_result["flow_id"],
user_input={"volume_resolution": 200, "input_sources": []},
)
assert configure_result["errors"] == {"input_sources": "empty_input_source_list"}
async def test_configure_no_resolution(
hass: HomeAssistant, default_mock_discovery
) -> None:
@ -404,33 +382,61 @@ async def test_configure_no_resolution(
)
async def test_configure_resolution_set(
hass: HomeAssistant, default_mock_discovery
) -> None:
"""Test receiver configure with specified resolution."""
async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None:
"""Test receiver configure."""
init_result = await hass.config_entries.flow.async_init(
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
form_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "manual"},
)
select_result = await hass.config_entries.flow.async_configure(
form_result["flow_id"],
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_HOST: "sample-host-name"},
)
configure_result = await hass.config_entries.flow.async_configure(
select_result["flow_id"],
user_input={"volume_resolution": 200, "input_sources": ["TV"]},
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
OPTION_VOLUME_RESOLUTION: 200,
OPTION_INPUT_SOURCES: [],
OPTION_LISTENING_MODES: ["THX"],
},
)
assert result["step_id"] == "configure_receiver"
assert result["errors"] == {OPTION_INPUT_SOURCES: "empty_input_source_list"}
assert configure_result["type"] is FlowResultType.CREATE_ENTRY
assert configure_result["options"]["volume_resolution"] == 200
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
OPTION_VOLUME_RESOLUTION: 200,
OPTION_INPUT_SOURCES: ["TV"],
OPTION_LISTENING_MODES: [],
},
)
assert result["step_id"] == "configure_receiver"
assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
OPTION_VOLUME_RESOLUTION: 200,
OPTION_INPUT_SOURCES: ["TV"],
OPTION_LISTENING_MODES: ["THX"],
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["options"] == {
OPTION_VOLUME_RESOLUTION: 200,
OPTION_MAX_VOLUME: OPTION_MAX_VOLUME_DEFAULT,
OPTION_INPUT_SOURCES: {"12": "TV"},
OPTION_LISTENING_MODES: {"04": "THX"},
}
async def test_configure_invalid_resolution_set(
@ -601,21 +607,26 @@ async def test_import_success(
await hass.async_block_till_done()
assert import_result["type"] is FlowResultType.CREATE_ENTRY
assert import_result["data"]["host"] == "host 1"
assert import_result["options"]["volume_resolution"] == 80
assert import_result["options"]["max_volume"] == 100
assert import_result["options"]["input_sources"] == {
"00": "Auxiliary",
"01": "Video",
assert import_result["data"] == {"host": "host 1"}
assert import_result["options"] == {
"volume_resolution": 80,
"max_volume": 100,
"input_sources": {
"00": "Auxiliary",
"01": "Video",
},
"listening_modes": {},
}
@pytest.mark.parametrize(
"ignore_translations",
[
[ # The schema is dynamically created from input sources
[ # The schema is dynamically created from input sources and listening modes
"component.onkyo.options.step.names.sections.input_sources.data.TV",
"component.onkyo.options.step.names.sections.input_sources.data_description.TV",
"component.onkyo.options.step.names.sections.listening_modes.data.STEREO",
"component.onkyo.options.step.names.sections.listening_modes.data_description.STEREO",
]
],
)
@ -635,6 +646,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry)
user_input={
OPTION_MAX_VOLUME: 42,
OPTION_INPUT_SOURCES: [],
OPTION_LISTENING_MODES: ["STEREO"],
},
)
@ -647,6 +659,20 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry)
user_input={
OPTION_MAX_VOLUME: 42,
OPTION_INPUT_SOURCES: ["TV"],
OPTION_LISTENING_MODES: [],
},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "init"
assert result["errors"] == {OPTION_LISTENING_MODES: "empty_listening_mode_list"}
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
OPTION_MAX_VOLUME: 42,
OPTION_INPUT_SOURCES: ["TV"],
OPTION_LISTENING_MODES: ["STEREO"],
},
)
@ -657,6 +683,7 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry)
result["flow_id"],
user_input={
OPTION_INPUT_SOURCES: {"TV": "television"},
OPTION_LISTENING_MODES: {"STEREO": "Duophonia"},
},
)
@ -665,4 +692,5 @@ async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry)
OPTION_VOLUME_RESOLUTION: old_volume_resolution,
OPTION_MAX_VOLUME: 42.0,
OPTION_INPUT_SOURCES: {"12": "television"},
OPTION_LISTENING_MODES: {"00": "Duophonia"},
}