Switch to a new library in Onkyo (#148613)

This commit is contained in:
Artur Pragacz 2025-07-21 16:31:28 +02:00 committed by GitHub
parent 80b96b0007
commit 40252763d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1255 additions and 1004 deletions

View File

@ -17,7 +17,7 @@ from .const import (
InputSource, InputSource,
ListeningMode, ListeningMode,
) )
from .receiver import Receiver, async_interview from .receiver import ReceiverManager, async_interview
from .services import DATA_MP_ENTITIES, async_setup_services from .services import DATA_MP_ENTITIES, async_setup_services
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -31,7 +31,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
class OnkyoData: class OnkyoData:
"""Config Entry data.""" """Config Entry data."""
receiver: Receiver manager: ReceiverManager
sources: dict[InputSource, str] sources: dict[InputSource, str]
sound_modes: dict[ListeningMode, str] sound_modes: dict[ListeningMode, str]
@ -50,11 +50,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
host = entry.data[CONF_HOST] host = entry.data[CONF_HOST]
info = await async_interview(host) try:
info = await async_interview(host)
except OSError as exc:
raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc
if info is None: if info is None:
raise ConfigEntryNotReady(f"Unable to connect to: {host}") raise ConfigEntryNotReady(f"Unable to connect to: {host}")
receiver = await Receiver.async_create(info) manager = ReceiverManager(hass, entry, info)
sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES] sources_store: dict[str, str] = entry.options[OPTION_INPUT_SOURCES]
sources = {InputSource(k): v for k, v in sources_store.items()} sources = {InputSource(k): v for k, v in sources_store.items()}
@ -62,11 +65,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> boo
sound_modes_store: dict[str, str] = entry.options.get(OPTION_LISTENING_MODES, {}) 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()} sound_modes = {ListeningMode(k): v for k, v in sound_modes_store.items()}
entry.runtime_data = OnkyoData(receiver, sources, sound_modes) entry.runtime_data = OnkyoData(manager, sources, sound_modes)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await receiver.conn.connect() if error := await manager.start():
try:
await error
except OSError as exc:
raise ConfigEntryNotReady(f"Unable to connect to: {host}") from exc
return True return True
@ -75,9 +82,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: OnkyoConfigEntry) -> bo
"""Unload Onkyo config entry.""" """Unload Onkyo config entry."""
del hass.data[DATA_MP_ENTITIES][entry.entry_id] del hass.data[DATA_MP_ENTITIES][entry.entry_id]
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) entry.runtime_data.manager.start_unloading()
receiver = entry.runtime_data.receiver return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
receiver.conn.close()
return unload_ok

View File

@ -4,12 +4,12 @@ from collections.abc import Mapping
import logging import logging
from typing import Any from typing import Any
from aioonkyo import ReceiverInfo
import voluptuous as vol import voluptuous as vol
from yarl import URL from yarl import URL
from homeassistant.config_entries import ( from homeassistant.config_entries import (
SOURCE_RECONFIGURE, SOURCE_RECONFIGURE,
ConfigEntry,
ConfigFlow, ConfigFlow,
ConfigFlowResult, ConfigFlowResult,
OptionsFlowWithReload, OptionsFlowWithReload,
@ -29,6 +29,7 @@ from homeassistant.helpers.selector import (
) )
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from . import OnkyoConfigEntry
from .const import ( from .const import (
DOMAIN, DOMAIN,
OPTION_INPUT_SOURCES, OPTION_INPUT_SOURCES,
@ -41,19 +42,20 @@ from .const import (
InputSource, InputSource,
ListeningMode, ListeningMode,
) )
from .receiver import ReceiverInfo, async_discover, async_interview from .receiver import async_discover, async_interview
from .util import get_meaning
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_DEVICE = "device" CONF_DEVICE = "device"
INPUT_SOURCES_DEFAULT: dict[str, str] = {} INPUT_SOURCES_DEFAULT: list[InputSource] = []
LISTENING_MODES_DEFAULT: dict[str, str] = {} LISTENING_MODES_DEFAULT: list[ListeningMode] = []
INPUT_SOURCES_ALL_MEANINGS = { INPUT_SOURCES_ALL_MEANINGS = {
input_source.value_meaning: input_source for input_source in InputSource get_meaning(input_source): input_source for input_source in InputSource
} }
LISTENING_MODES_ALL_MEANINGS = { LISTENING_MODES_ALL_MEANINGS = {
listening_mode.value_meaning: listening_mode for listening_mode in ListeningMode get_meaning(listening_mode): listening_mode for listening_mode in ListeningMode
} }
STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) STEP_MANUAL_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
STEP_RECONFIGURE_SCHEMA = vol.Schema( STEP_RECONFIGURE_SCHEMA = vol.Schema(
@ -91,6 +93,7 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle a flow initialized by the user.""" """Handle a flow initialized by the user."""
_LOGGER.debug("Config flow start user")
return self.async_show_menu( return self.async_show_menu(
step_id="user", menu_options=["manual", "eiscp_discovery"] step_id="user", menu_options=["manual", "eiscp_discovery"]
) )
@ -103,10 +106,10 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
if user_input is not None: if user_input is not None:
host = user_input[CONF_HOST] host = user_input[CONF_HOST]
_LOGGER.debug("Config flow start manual: %s", host) _LOGGER.debug("Config flow manual: %s", host)
try: try:
info = await async_interview(host) info = await async_interview(host)
except Exception: except OSError:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
errors["base"] = "unknown" errors["base"] = "unknown"
else: else:
@ -156,8 +159,8 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.debug("Config flow start eiscp discovery") _LOGGER.debug("Config flow start eiscp discovery")
try: try:
infos = await async_discover() infos = list(await async_discover(self.hass))
except Exception: except OSError:
_LOGGER.exception("Unexpected exception") _LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown") return self.async_abort(reason="unknown")
@ -303,8 +306,14 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
if reconfigure_entry is None: if reconfigure_entry is None:
suggested_values = { suggested_values = {
OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT, OPTION_VOLUME_RESOLUTION: OPTION_VOLUME_RESOLUTION_DEFAULT,
OPTION_INPUT_SOURCES: INPUT_SOURCES_DEFAULT, OPTION_INPUT_SOURCES: [
OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, get_meaning(input_source)
for input_source in INPUT_SOURCES_DEFAULT
],
OPTION_LISTENING_MODES: [
get_meaning(listening_mode)
for listening_mode in LISTENING_MODES_DEFAULT
],
} }
else: else:
entry_options = reconfigure_entry.options entry_options = reconfigure_entry.options
@ -325,11 +334,12 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult: ) -> ConfigFlowResult:
"""Handle reconfiguration of the receiver.""" """Handle reconfiguration of the receiver."""
_LOGGER.debug("Config flow start reconfigure")
return await self.async_step_manual() return await self.async_step_manual()
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowWithReload: def async_get_options_flow(config_entry: OnkyoConfigEntry) -> OptionsFlowWithReload:
"""Return the options flow.""" """Return the options flow."""
return OnkyoOptionsFlowHandler() return OnkyoOptionsFlowHandler()
@ -372,7 +382,10 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload):
entry_options: Mapping[str, Any] = self.config_entry.options entry_options: Mapping[str, Any] = self.config_entry.options
entry_options = { entry_options = {
OPTION_LISTENING_MODES: LISTENING_MODES_DEFAULT, OPTION_LISTENING_MODES: {
listening_mode.value: get_meaning(listening_mode)
for listening_mode in LISTENING_MODES_DEFAULT
},
**entry_options, **entry_options,
} }
@ -416,11 +429,11 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload):
suggested_values = { suggested_values = {
OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME], OPTION_MAX_VOLUME: entry_options[OPTION_MAX_VOLUME],
OPTION_INPUT_SOURCES: [ OPTION_INPUT_SOURCES: [
InputSource(input_source).value_meaning get_meaning(InputSource(input_source))
for input_source in entry_options[OPTION_INPUT_SOURCES] for input_source in entry_options[OPTION_INPUT_SOURCES]
], ],
OPTION_LISTENING_MODES: [ OPTION_LISTENING_MODES: [
ListeningMode(listening_mode).value_meaning get_meaning(ListeningMode(listening_mode))
for listening_mode in entry_options[OPTION_LISTENING_MODES] for listening_mode in entry_options[OPTION_LISTENING_MODES]
], ],
} }
@ -463,13 +476,13 @@ class OnkyoOptionsFlowHandler(OptionsFlowWithReload):
input_sources_schema_dict: dict[Any, Selector] = {} input_sources_schema_dict: dict[Any, Selector] = {}
for input_source, input_source_name in self._input_sources.items(): for input_source, input_source_name in self._input_sources.items():
input_sources_schema_dict[ input_sources_schema_dict[
vol.Required(input_source.value_meaning, default=input_source_name) vol.Required(get_meaning(input_source), default=input_source_name)
] = TextSelector() ] = TextSelector()
listening_modes_schema_dict: dict[Any, Selector] = {} listening_modes_schema_dict: dict[Any, Selector] = {}
for listening_mode, listening_mode_name in self._listening_modes.items(): for listening_mode, listening_mode_name in self._listening_modes.items():
listening_modes_schema_dict[ listening_modes_schema_dict[
vol.Required(listening_mode.value_meaning, default=listening_mode_name) vol.Required(get_meaning(listening_mode), default=listening_mode_name)
] = TextSelector() ] = TextSelector()
return self.async_show_form( return self.async_show_form(

View File

@ -1,10 +1,9 @@
"""Constants for the Onkyo integration.""" """Constants for the Onkyo integration."""
from enum import Enum
import typing import typing
from typing import Literal, Self from typing import Literal
import pyeiscp from aioonkyo import HDMIOutputParam, InputSourceParam, ListeningModeParam, Zone
DOMAIN = "onkyo" DOMAIN = "onkyo"
@ -21,214 +20,37 @@ VOLUME_RESOLUTION_ALLOWED: tuple[VolumeResolution, ...] = typing.get_args(
OPTION_MAX_VOLUME = "max_volume" OPTION_MAX_VOLUME = "max_volume"
OPTION_MAX_VOLUME_DEFAULT = 100.0 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_INPUT_SOURCES = "input_sources"
OPTION_LISTENING_MODES = "listening_modes" OPTION_LISTENING_MODES = "listening_modes"
_INPUT_SOURCE_MEANINGS = { InputSource = InputSourceParam
"00": "VIDEO1 ··· VCR/DVR ··· STB/DVR", ListeningMode = ListeningModeParam
"01": "VIDEO2 ··· CBL/SAT", HDMIOutput = HDMIOutputParam
"02": "VIDEO3 ··· GAME/TV ··· GAME",
"03": "VIDEO4 ··· AUX", ZONES = {
"04": "VIDEO5 ··· AUX2 ··· GAME2", Zone.MAIN: "Main",
"05": "VIDEO6 ··· PC", Zone.ZONE2: "Zone 2",
"06": "VIDEO7", Zone.ZONE3: "Zone 3",
"07": "HIDDEN1 ··· EXTRA1", Zone.ZONE4: "Zone 4",
"08": "HIDDEN2 ··· EXTRA2",
"09": "HIDDEN3 ··· EXTRA3",
"10": "DVD ··· BD/DVD",
"11": "STRM BOX",
"12": "TV",
"20": "TAPE ··· TV/TAPE",
"21": "TAPE2",
"22": "PHONO",
"23": "CD ··· TV/CD",
"24": "FM",
"25": "AM",
"26": "TUNER",
"27": "MUSIC SERVER ··· P4S ··· DLNA",
"28": "INTERNET RADIO ··· IRADIO FAVORITE",
"29": "USB ··· USB(FRONT)",
"2A": "USB(REAR)",
"2B": "NETWORK ··· NET",
"2D": "AIRPLAY",
"2E": "BLUETOOTH",
"2F": "USB DAC IN",
"30": "MULTI CH",
"31": "XM",
"32": "SIRIUS",
"33": "DAB",
"40": "UNIVERSAL PORT",
"41": "LINE",
"42": "LINE2",
"44": "OPTICAL",
"45": "COAXIAL",
"55": "HDMI 5",
"56": "HDMI 6",
"57": "HDMI 7",
"80": "MAIN SOURCE",
} }
class InputSource(EnumWithMeaning): LEGACY_HDMI_OUTPUT_MAPPING = {
"""Receiver input source.""" HDMIOutput.ANALOG: "no,analog",
HDMIOutput.MAIN: "yes,out",
DVR = "00" HDMIOutput.SUB: "out-sub,sub,hdbaset",
CBL = "01" HDMIOutput.BOTH: "both,sub",
GAME = "02" HDMIOutput.BOTH_MAIN: "both",
AUX = "03" HDMIOutput.BOTH_SUB: "both",
GAME2 = "04"
PC = "05"
VIDEO7 = "06"
EXTRA1 = "07"
EXTRA2 = "08"
EXTRA3 = "09"
DVD = "10"
STRM_BOX = "11"
TV = "12"
TAPE = "20"
TAPE2 = "21"
PHONO = "22"
CD = "23"
FM = "24"
AM = "25"
TUNER = "26"
MUSIC_SERVER = "27"
INTERNET_RADIO = "28"
USB = "29"
USB_REAR = "2A"
NETWORK = "2B"
AIRPLAY = "2D"
BLUETOOTH = "2E"
USB_DAC_IN = "2F"
MULTI_CH = "30"
XM = "31"
SIRIUS = "32"
DAB = "33"
UNIVERSAL_PORT = "40"
LINE = "41"
LINE2 = "42"
OPTICAL = "44"
COAXIAL = "45"
HDMI_5 = "55"
HDMI_6 = "56"
HDMI_7 = "57"
MAIN_SOURCE = "80"
@staticmethod
def _get_meanings() -> dict[str, str]:
return _INPUT_SOURCE_MEANINGS
_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",
} }
LEGACY_REV_HDMI_OUTPUT_MAPPING = {
class ListeningMode(EnumWithMeaning): "analog": HDMIOutput.ANALOG,
"""Receiver listening mode.""" "both": HDMIOutput.BOTH_SUB,
"hdbaset": HDMIOutput.SUB,
_ignore_ = "ListeningMode _k _v _meaning" "no": HDMIOutput.ANALOG,
"out": HDMIOutput.MAIN,
ListeningMode = vars() "out-sub": HDMIOutput.SUB,
for _k in _LISTENING_MODE_MEANINGS: "sub": HDMIOutput.BOTH,
ListeningMode["I" + _k] = _k "yes": HDMIOutput.MAIN,
}
@staticmethod
def _get_meanings() -> dict[str, str]:
return _LISTENING_MODE_MEANINGS
ZONES = {"main": "Main", "zone2": "Zone 2", "zone3": "Zone 3", "zone4": "Zone 4"}
PYEISCP_COMMANDS = pyeiscp.commands.COMMANDS

View File

@ -3,11 +3,12 @@
"name": "Onkyo", "name": "Onkyo",
"codeowners": ["@arturpragacz", "@eclair4151"], "codeowners": ["@arturpragacz", "@eclair4151"],
"config_flow": true, "config_flow": true,
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/onkyo", "documentation": "https://www.home-assistant.io/integrations/onkyo",
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyeiscp"], "loggers": ["aioonkyo"],
"requirements": ["pyeiscp==0.0.7"], "requirements": ["aioonkyo==0.2.0"],
"ssdp": [ "ssdp": [
{ {
"manufacturer": "ONKYO", "manufacturer": "ONKYO",

View File

@ -1,12 +1,12 @@
"""Support for Onkyo Receivers.""" """Media player platform."""
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from enum import Enum
from functools import cache
import logging import logging
from typing import Any, Literal from typing import Any
from aioonkyo import Code, Kind, Status, Zone, command, query, status
from homeassistant.components.media_player import ( from homeassistant.components.media_player import (
MediaPlayerEntity, MediaPlayerEntity,
@ -14,23 +14,25 @@ from homeassistant.components.media_player import (
MediaPlayerState, MediaPlayerState,
MediaType, MediaType,
) )
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import OnkyoConfigEntry from . import OnkyoConfigEntry
from .const import ( from .const import (
DOMAIN, DOMAIN,
LEGACY_HDMI_OUTPUT_MAPPING,
LEGACY_REV_HDMI_OUTPUT_MAPPING,
OPTION_MAX_VOLUME, OPTION_MAX_VOLUME,
OPTION_VOLUME_RESOLUTION, OPTION_VOLUME_RESOLUTION,
PYEISCP_COMMANDS,
ZONES, ZONES,
InputSource, InputSource,
ListeningMode, ListeningMode,
VolumeResolution, VolumeResolution,
) )
from .receiver import Receiver from .receiver import ReceiverManager
from .services import DATA_MP_ENTITIES from .services import DATA_MP_ENTITIES
from .util import get_meaning
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -86,64 +88,6 @@ VIDEO_INFORMATION_MAPPING = [
"input_hdr", "input_hdr",
] ]
type LibValue = str | tuple[str, ...]
def _get_single_lib_value(value: LibValue) -> str:
if isinstance(value, str):
return value
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
def _input_source_lib_mappings(zone: str) -> dict[InputSource, LibValue]:
match zone:
case "main":
cmds = PYEISCP_COMMANDS["main"]["SLI"]
case "zone2":
cmds = PYEISCP_COMMANDS["zone2"]["SLZ"]
case "zone3":
cmds = PYEISCP_COMMANDS["zone3"]["SL3"]
case "zone4":
cmds = PYEISCP_COMMANDS["zone4"]["SL4"]
return _get_lib_mapping(cmds, InputSource)
@cache
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_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -153,10 +97,10 @@ async def async_setup_entry(
"""Set up MediaPlayer for config entry.""" """Set up MediaPlayer for config entry."""
data = entry.runtime_data data = entry.runtime_data
receiver = data.receiver manager = data.manager
all_entities = hass.data[DATA_MP_ENTITIES] all_entities = hass.data[DATA_MP_ENTITIES]
entities: dict[str, OnkyoMediaPlayer] = {} entities: dict[Zone, OnkyoMediaPlayer] = {}
all_entities[entry.entry_id] = entities all_entities[entry.entry_id] = entities
volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION] volume_resolution: VolumeResolution = entry.options[OPTION_VOLUME_RESOLUTION]
@ -164,29 +108,33 @@ async def async_setup_entry(
sources = data.sources sources = data.sources
sound_modes = data.sound_modes sound_modes = data.sound_modes
def connect_callback(receiver: Receiver) -> None: async def connect_callback(reconnect: bool) -> None:
if not receiver.first_connect: if reconnect:
for entity in entities.values(): for entity in entities.values():
if entity.enabled: if entity.enabled:
entity.backfill_state() await entity.backfill_state()
async def update_callback(message: Status) -> None:
if isinstance(message, status.Raw):
return
zone = message.zone
def update_callback(receiver: Receiver, message: tuple[str, str, Any]) -> None:
zone, _, value = message
entity = entities.get(zone) entity = entities.get(zone)
if entity is not None: if entity is not None:
if entity.enabled: if entity.enabled:
entity.process_update(message) entity.process_update(message)
elif zone in ZONES and value != "N/A": elif not isinstance(message, status.NotAvailable):
# When we receive the status for a zone, and the value is not "N/A", # When we receive a valid status for a zone, then that zone is available on the receiver,
# then zone is available on the receiver, so we create the entity for it. # so we create the entity for it.
_LOGGER.debug( _LOGGER.debug(
"Discovered %s on %s (%s)", "Discovered %s on %s (%s)",
ZONES[zone], ZONES[zone],
receiver.model_name, manager.info.model_name,
receiver.host, manager.info.host,
) )
zone_entity = OnkyoMediaPlayer( zone_entity = OnkyoMediaPlayer(
receiver, manager,
zone, zone,
volume_resolution=volume_resolution, volume_resolution=volume_resolution,
max_volume=max_volume, max_volume=max_volume,
@ -196,25 +144,27 @@ async def async_setup_entry(
entities[zone] = zone_entity entities[zone] = zone_entity
async_add_entities([zone_entity]) async_add_entities([zone_entity])
receiver.callbacks.connect.append(connect_callback) manager.callbacks.connect.append(connect_callback)
receiver.callbacks.update.append(update_callback) manager.callbacks.update.append(update_callback)
class OnkyoMediaPlayer(MediaPlayerEntity): class OnkyoMediaPlayer(MediaPlayerEntity):
"""Representation of an Onkyo Receiver Media Player (one per each zone).""" """Onkyo Receiver Media Player (one per each zone)."""
_attr_should_poll = False _attr_should_poll = False
_supports_volume: bool = False _supports_volume: bool = False
_supports_sound_mode: bool = False # None means no technical possibility of support
_supports_sound_mode: bool | None = None
_supports_audio_info: bool = False _supports_audio_info: bool = False
_supports_video_info: bool = False _supports_video_info: bool = False
_query_timer: asyncio.TimerHandle | None = None
_query_task: asyncio.Task | None = None
def __init__( def __init__(
self, self,
receiver: Receiver, manager: ReceiverManager,
zone: str, zone: Zone,
*, *,
volume_resolution: VolumeResolution, volume_resolution: VolumeResolution,
max_volume: float, max_volume: float,
@ -222,80 +172,88 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
sound_modes: dict[ListeningMode, str], sound_modes: dict[ListeningMode, str],
) -> None: ) -> None:
"""Initialize the Onkyo Receiver.""" """Initialize the Onkyo Receiver."""
self._receiver = receiver self._manager = manager
name = receiver.model_name
identifier = receiver.identifier
self._attr_name = f"{name}{' ' + ZONES[zone] if zone != 'main' else ''}"
self._attr_unique_id = f"{identifier}_{zone}"
self._zone = zone self._zone = zone
name = manager.info.model_name
identifier = manager.info.identifier
self._attr_name = f"{name}{' ' + ZONES[zone] if zone != Zone.MAIN else ''}"
self._attr_unique_id = f"{identifier}_{zone.value}"
self._volume_resolution = volume_resolution self._volume_resolution = volume_resolution
self._max_volume = max_volume self._max_volume = max_volume
self._options_sources = sources zone_sources = InputSource.for_zone(zone)
self._source_lib_mapping = _input_source_lib_mappings(zone)
self._rev_source_lib_mapping = _rev_input_source_lib_mappings(zone)
self._source_mapping = { self._source_mapping = {
key: value key: value for key, value in sources.items() if key in zone_sources
for key, value in sources.items()
if key in self._source_lib_mapping
} }
self._rev_source_mapping = { self._rev_source_mapping = {
value: key for key, value in self._source_mapping.items() value: key for key, value in self._source_mapping.items()
} }
self._options_sound_modes = sound_modes zone_sound_modes = ListeningMode.for_zone(zone)
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 = { self._sound_mode_mapping = {
key: value key: value for key, value in sound_modes.items() if key in zone_sound_modes
for key, value in sound_modes.items()
if key in self._sound_mode_lib_mapping
} }
self._rev_sound_mode_mapping = { self._rev_sound_mode_mapping = {
value: key for key, value in self._sound_mode_mapping.items() value: key for key, value in self._sound_mode_mapping.items()
} }
self._hdmi_output_mapping = LEGACY_HDMI_OUTPUT_MAPPING
self._rev_hdmi_output_mapping = LEGACY_REV_HDMI_OUTPUT_MAPPING
self._attr_source_list = list(self._rev_source_mapping) self._attr_source_list = list(self._rev_source_mapping)
self._attr_sound_mode_list = list(self._rev_sound_mode_mapping) self._attr_sound_mode_list = list(self._rev_sound_mode_mapping)
self._attr_supported_features = SUPPORTED_FEATURES_BASE self._attr_supported_features = SUPPORTED_FEATURES_BASE
if zone == "main": if zone == Zone.MAIN:
self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
self._supports_volume = True self._supports_volume = True
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._supports_sound_mode = True self._supports_sound_mode = True
elif Code.get_from_kind_zone(Kind.LISTENING_MODE, zone) is not None:
# To be detected later:
self._supports_sound_mode = False
self._attr_extra_state_attributes = {} self._attr_extra_state_attributes = {}
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Entity has been added to hass.""" """Entity has been added to hass."""
self.backfill_state() await self.backfill_state()
async def async_will_remove_from_hass(self) -> None: async def async_will_remove_from_hass(self) -> None:
"""Cancel the query timer when the entity is removed.""" """Cancel the query timer when the entity is removed."""
if self._query_timer: if self._query_task:
self._query_timer.cancel() self._query_task.cancel()
self._query_timer = None self._query_task = None
@callback async def backfill_state(self) -> None:
def _update_receiver(self, propname: str, value: Any) -> None: """Get the receiver to send all the info we care about.
"""Update a property in the receiver."""
self._receiver.conn.update_property(self._zone, propname, value)
@callback Usually run only on connect, as we can otherwise rely on the
def _query_receiver(self, propname: str) -> None: receiver to keep us informed of changes.
"""Cause the receiver to send an update about a property.""" """
self._receiver.conn.query_property(self._zone, propname) await self._manager.write(query.Power(self._zone))
await self._manager.write(query.Volume(self._zone))
await self._manager.write(query.Muting(self._zone))
await self._manager.write(query.InputSource(self._zone))
await self._manager.write(query.TunerPreset(self._zone))
if self._supports_sound_mode is not None:
await self._manager.write(query.ListeningMode(self._zone))
if self._zone == Zone.MAIN:
await self._manager.write(query.HDMIOutput())
await self._manager.write(query.AudioInformation())
await self._manager.write(query.VideoInformation())
async def async_turn_on(self) -> None: async def async_turn_on(self) -> None:
"""Turn the media player on.""" """Turn the media player on."""
self._update_receiver("power", "on") message = command.Power(self._zone, command.Power.Param.ON)
await self._manager.write(message)
async def async_turn_off(self) -> None: async def async_turn_off(self) -> None:
"""Turn the media player off.""" """Turn the media player off."""
self._update_receiver("power", "standby") message = command.Power(self._zone, command.Power.Param.STANDBY)
await self._manager.write(message)
async def async_set_volume_level(self, volume: float) -> None: async def async_set_volume_level(self, volume: float) -> None:
"""Set volume level, range 0..1. """Set volume level, range 0..1.
@ -307,28 +265,30 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
scale for the receiver. scale for the receiver.
""" """
# HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION # HA_VOL * (MAX VOL / 100) * VOL_RESOLUTION
self._update_receiver( value = round(volume * (self._max_volume / 100) * self._volume_resolution)
"volume", round(volume * (self._max_volume / 100) * self._volume_resolution) message = command.Volume(self._zone, value)
) await self._manager.write(message)
async def async_volume_up(self) -> None: async def async_volume_up(self) -> None:
"""Increase volume by 1 step.""" """Increase volume by 1 step."""
self._update_receiver("volume", "level-up") message = command.Volume(self._zone, command.Volume.Param.UP)
await self._manager.write(message)
async def async_volume_down(self) -> None: async def async_volume_down(self) -> None:
"""Decrease volume by 1 step.""" """Decrease volume by 1 step."""
self._update_receiver("volume", "level-down") message = command.Volume(self._zone, command.Volume.Param.DOWN)
await self._manager.write(message)
async def async_mute_volume(self, mute: bool) -> None: async def async_mute_volume(self, mute: bool) -> None:
"""Mute the volume.""" """Mute the volume."""
self._update_receiver( message = command.Muting(
"audio-muting" if self._zone == "main" else "muting", self._zone, command.Muting.Param.ON if mute else command.Muting.Param.OFF
"on" if mute else "off",
) )
await self._manager.write(message)
async def async_select_source(self, source: str) -> None: async def async_select_source(self, source: str) -> None:
"""Select input source.""" """Select input source."""
if not self.source_list or source not in self.source_list: if source not in self._rev_source_mapping:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_source", translation_key="invalid_source",
@ -338,15 +298,12 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
}, },
) )
source_lib = self._source_lib_mapping[self._rev_source_mapping[source]] message = command.InputSource(self._zone, self._rev_source_mapping[source])
source_lib_single = _get_single_lib_value(source_lib) await self._manager.write(message)
self._update_receiver(
"input-selector" if self._zone == "main" else "selector", source_lib_single
)
async def async_select_sound_mode(self, sound_mode: str) -> None: async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select listening sound mode.""" """Select listening sound mode."""
if not self.sound_mode_list or sound_mode not in self.sound_mode_list: if sound_mode not in self._rev_sound_mode_mapping:
raise ServiceValidationError( raise ServiceValidationError(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="invalid_sound_mode", translation_key="invalid_sound_mode",
@ -356,197 +313,138 @@ class OnkyoMediaPlayer(MediaPlayerEntity):
}, },
) )
sound_mode_lib = self._sound_mode_lib_mapping[ message = command.ListeningMode(
self._rev_sound_mode_mapping[sound_mode] self._zone, self._rev_sound_mode_mapping[sound_mode]
] )
sound_mode_lib_single = _get_single_lib_value(sound_mode_lib) await self._manager.write(message)
self._update_receiver("listening-mode", sound_mode_lib_single)
async def async_select_output(self, hdmi_output: str) -> None: async def async_select_output(self, hdmi_output: str) -> None:
"""Set hdmi-out.""" """Set hdmi-out."""
self._update_receiver("hdmi-output-selector", hdmi_output) message = command.HDMIOutput(self._rev_hdmi_output_mapping[hdmi_output])
await self._manager.write(message)
async def async_play_media( async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None: ) -> None:
"""Play radio station by preset number.""" """Play radio station by preset number."""
if self.source is not None: if self.source is None:
source = self._rev_source_mapping[self.source]
if media_type.lower() == "radio" and source in PLAYABLE_SOURCES:
self._update_receiver("preset", media_id)
@callback
def backfill_state(self) -> None:
"""Get the receiver to send all the info we care about.
Usually run only on connect, as we can otherwise rely on the
receiver to keep us informed of changes.
"""
self._query_receiver("power")
self._query_receiver("volume")
self._query_receiver("preset")
if self._zone == "main":
self._query_receiver("hdmi-output-selector")
self._query_receiver("audio-muting")
self._query_receiver("input-selector")
self._query_receiver("listening-mode")
self._query_receiver("audio-information")
self._query_receiver("video-information")
else:
self._query_receiver("muting")
self._query_receiver("selector")
@callback
def process_update(self, update: tuple[str, str, Any]) -> None:
"""Store relevant updates so they can be queried later."""
zone, command, value = update
if zone != self._zone:
return return
if command in ["system-power", "power"]: source = self._rev_source_mapping.get(self.source)
if value == "on": if media_type.lower() != "radio" or source not in PLAYABLE_SOURCES:
return
message = command.TunerPreset(self._zone, int(media_id))
await self._manager.write(message)
def process_update(self, message: status.Known) -> None:
"""Process update."""
match message:
case status.Power(status.Power.Param.ON):
self._attr_state = MediaPlayerState.ON self._attr_state = MediaPlayerState.ON
else: case status.Power(status.Power.Param.STANDBY):
self._attr_state = MediaPlayerState.OFF 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) case status.Volume(volume):
self._attr_extra_state_attributes.pop(ATTR_PRESET, None) if not self._supports_volume:
self._attr_extra_state_attributes.pop(ATTR_VIDEO_OUT, None) self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME
elif command in ["volume", "master-volume"] and value != "N/A": self._supports_volume = True
if not self._supports_volume: # AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100))
self._attr_supported_features |= SUPPORTED_FEATURES_VOLUME volume_level: float = volume / (
self._supports_volume = True self._volume_resolution * self._max_volume / 100
# AMP_VOL / (VOL_RESOLUTION * (MAX_VOL / 100))
volume_level: float = value / (
self._volume_resolution * self._max_volume / 100
)
self._attr_volume_level = min(1, volume_level)
elif command in ["muting", "audio-muting"]:
self._attr_is_volume_muted = bool(value == "on")
elif command in ["selector", "input-selector"] and value != "N/A":
self._parse_source(value)
self._query_av_info_delayed()
elif command == "hdmi-output-selector":
self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = ",".join(value)
elif command == "preset":
if self.source is not None and self.source.lower() == "radio":
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._attr_volume_level = min(1, volume_level)
self._parse_sound_mode(value)
self._query_av_info_delayed() case status.Muting(muting):
elif command == "audio-information": self._attr_is_volume_muted = bool(muting == status.Muting.Param.ON)
self._supports_audio_info = True
self._parse_audio_information(value) case status.InputSource(source):
elif command == "video-information": if source in self._source_mapping:
self._supports_video_info = True self._attr_source = self._source_mapping[source]
self._parse_video_information(value) else:
elif command == "fl-display-information": source_meaning = get_meaning(source)
self._query_av_info_delayed() _LOGGER.warning(
'Input source "%s" for entity: %s is not in the list. Check integration options',
source_meaning,
self.entity_id,
)
self._attr_source = source_meaning
self._query_av_info_delayed()
case status.ListeningMode(sound_mode):
if not self._supports_sound_mode:
self._attr_supported_features |= (
MediaPlayerEntityFeature.SELECT_SOUND_MODE
)
self._supports_sound_mode = True
if sound_mode in self._sound_mode_mapping:
self._attr_sound_mode = self._sound_mode_mapping[sound_mode]
else:
sound_mode_meaning = get_meaning(sound_mode)
_LOGGER.warning(
'Listening mode "%s" for entity: %s is not in the list. Check integration options',
sound_mode_meaning,
self.entity_id,
)
self._attr_sound_mode = sound_mode_meaning
self._query_av_info_delayed()
case status.HDMIOutput(hdmi_output):
self._attr_extra_state_attributes[ATTR_VIDEO_OUT] = (
self._hdmi_output_mapping[hdmi_output]
)
self._query_av_info_delayed()
case status.TunerPreset(preset):
self._attr_extra_state_attributes[ATTR_PRESET] = preset
case status.AudioInformation():
self._supports_audio_info = True
audio_information = {}
for item in AUDIO_INFORMATION_MAPPING:
item_value = getattr(message, item)
if item_value is not None:
audio_information[item] = item_value
self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = (
audio_information
)
case status.VideoInformation():
self._supports_video_info = True
video_information = {}
for item in VIDEO_INFORMATION_MAPPING:
item_value = getattr(message, item)
if item_value is not None:
video_information[item] = item_value
self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = (
video_information
)
case status.FLDisplay():
self._query_av_info_delayed()
case status.NotAvailable(Kind.AUDIO_INFORMATION):
# Not available right now, but still supported
self._supports_audio_info = True
case status.NotAvailable(Kind.VIDEO_INFORMATION):
# Not available right now, but still supported
self._supports_video_info = True
self.async_write_ha_state() self.async_write_ha_state()
@callback
def _parse_source(self, source_lib: LibValue) -> None:
source = self._rev_source_lib_mapping[source_lib]
if source in self._source_mapping:
self._attr_source = self._source_mapping[source]
return
source_meaning = source.value_meaning
if source not in self._options_sources:
_LOGGER.warning(
'Input source "%s" for entity: %s is not in the list. Check integration options',
source_meaning,
self.entity_id,
)
else:
_LOGGER.error(
'Input source "%s" is invalid for entity: %s',
source_meaning,
self.entity_id,
)
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
if sound_mode not in self._options_sound_modes:
_LOGGER.warning(
'Listening mode "%s" for entity: %s is not in the list. Check integration options',
sound_mode_meaning,
self.entity_id,
)
else:
_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"]
) -> None:
# If audio information is not available, N/A is returned,
# so only update the audio information, when it is not N/A.
if audio_information == "N/A":
self._attr_extra_state_attributes.pop(ATTR_AUDIO_INFORMATION, None)
return
self._attr_extra_state_attributes[ATTR_AUDIO_INFORMATION] = {
name: value
for name, value in zip(
AUDIO_INFORMATION_MAPPING, audio_information, strict=False
)
if len(value) > 0
}
@callback
def _parse_video_information(
self, video_information: tuple[str] | Literal["N/A"]
) -> None:
# If video information is not available, N/A is returned,
# so only update the video information, when it is not N/A.
if video_information == "N/A":
self._attr_extra_state_attributes.pop(ATTR_VIDEO_INFORMATION, None)
return
self._attr_extra_state_attributes[ATTR_VIDEO_INFORMATION] = {
name: value
for name, value in zip(
VIDEO_INFORMATION_MAPPING, video_information, strict=False
)
if len(value) > 0
}
def _query_av_info_delayed(self) -> None: def _query_av_info_delayed(self) -> None:
if self._zone == "main" and not self._query_timer: if self._zone == Zone.MAIN and not self._query_task:
@callback async def _query_av_info() -> None:
def _query_av_info() -> None: await asyncio.sleep(AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME)
if self._supports_audio_info: if self._supports_audio_info:
self._query_receiver("audio-information") await self._manager.write(query.AudioInformation())
if self._supports_video_info: if self._supports_video_info:
self._query_receiver("video-information") await self._manager.write(query.VideoInformation())
self._query_timer = None self._query_task = None
self._query_timer = self.hass.loop.call_later( self._query_task = asyncio.create_task(_query_av_info())
AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME, _query_av_info
)

View File

@ -77,7 +77,4 @@ rules:
status: exempt status: exempt
comment: | comment: |
This integration is not making any HTTP requests. This integration is not making any HTTP requests.
strict-typing: strict-typing: done
status: todo
comment: |
The library is not fully typed yet.

View File

@ -3,149 +3,149 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from collections.abc import Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
import contextlib import contextlib
from dataclasses import dataclass, field from dataclasses import dataclass, field
import logging import logging
from typing import Any from typing import TYPE_CHECKING
import pyeiscp import aioonkyo
from aioonkyo import Instruction, Receiver, ReceiverInfo, Status, connect, query
from homeassistant.components import network
from homeassistant.core import HomeAssistant
from .const import DEVICE_DISCOVERY_TIMEOUT, DEVICE_INTERVIEW_TIMEOUT, ZONES from .const import DEVICE_DISCOVERY_TIMEOUT, DEVICE_INTERVIEW_TIMEOUT, ZONES
if TYPE_CHECKING:
from . import OnkyoConfigEntry
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@dataclass @dataclass
class Callbacks: class Callbacks:
"""Onkyo Receiver Callbacks.""" """Receiver callbacks."""
connect: list[Callable[[Receiver], None]] = field(default_factory=list) connect: list[Callable[[bool], Awaitable[None]]] = field(default_factory=list)
update: list[Callable[[Receiver, tuple[str, str, Any]], None]] = field( update: list[Callable[[Status], Awaitable[None]]] = field(default_factory=list)
default_factory=list
) def clear(self) -> None:
"""Clear all callbacks."""
self.connect.clear()
self.update.clear()
@dataclass class ReceiverManager:
class Receiver: """Receiver manager."""
"""Onkyo receiver."""
conn: pyeiscp.Connection hass: HomeAssistant
model_name: str entry: OnkyoConfigEntry
identifier: str info: ReceiverInfo
host: str receiver: Receiver | None = None
first_connect: bool = True callbacks: Callbacks
callbacks: Callbacks = field(default_factory=Callbacks)
@classmethod _started: asyncio.Event
async def async_create(cls, info: ReceiverInfo) -> Receiver:
"""Set up Onkyo Receiver."""
receiver: Receiver | None = None def __init__(
self, hass: HomeAssistant, entry: OnkyoConfigEntry, info: ReceiverInfo
) -> None:
"""Init receiver manager."""
self.hass = hass
self.entry = entry
self.info = info
self.callbacks = Callbacks()
self._started = asyncio.Event()
def on_connect(_origin: str) -> None: async def start(self) -> Awaitable[None] | None:
assert receiver is not None """Start the receiver manager run.
receiver.on_connect()
def on_update(message: tuple[str, str, Any], _origin: str) -> None: Returns `None`, if everything went fine.
assert receiver is not None Returns an awaitable with exception set, if something went wrong.
receiver.on_update(message) """
manager_task = self.entry.async_create_background_task(
_LOGGER.debug("Creating receiver: %s (%s)", info.model_name, info.host) self.hass, self._run(), "run_connection"
connection = await pyeiscp.Connection.create(
host=info.host,
port=info.port,
connect_callback=on_connect,
update_callback=on_update,
auto_connect=False,
) )
wait_for_started_task = asyncio.create_task(self._started.wait())
return ( done, _ = await asyncio.wait(
receiver := cls( (manager_task, wait_for_started_task), return_when=asyncio.FIRST_COMPLETED
conn=connection,
model_name=info.model_name,
identifier=info.identifier,
host=info.host,
)
) )
if manager_task in done:
# Something went wrong, so let's return the manager task,
# so that it can be awaited to error out
return manager_task
def on_connect(self) -> None: return None
async def _run(self) -> None:
"""Run the connection to the receiver."""
reconnect = False
while True:
try:
async with connect(self.info, retry=reconnect) as self.receiver:
if not reconnect:
self._started.set()
else:
_LOGGER.info("Reconnected: %s", self.info)
await self.on_connect(reconnect=reconnect)
while message := await self.receiver.read():
await self.on_update(message)
reconnect = True
finally:
_LOGGER.info("Disconnected: %s", self.info)
async def on_connect(self, reconnect: bool) -> None:
"""Receiver (re)connected.""" """Receiver (re)connected."""
_LOGGER.debug("Receiver (re)connected: %s (%s)", self.model_name, self.host)
# Discover what zones are available for the receiver by querying the power. # Discover what zones are available for the receiver by querying the power.
# If we get a response for the specific zone, it means it is available. # If we get a response for the specific zone, it means it is available.
for zone in ZONES: for zone in ZONES:
self.conn.query_property(zone, "power") await self.write(query.Power(zone))
for callback in self.callbacks.connect: for callback in self.callbacks.connect:
callback(self) await callback(reconnect)
self.first_connect = False async def on_update(self, message: Status) -> None:
def on_update(self, message: tuple[str, str, Any]) -> None:
"""Process new message from the receiver.""" """Process new message from the receiver."""
_LOGGER.debug("Received update callback from %s: %s", self.model_name, message)
for callback in self.callbacks.update: for callback in self.callbacks.update:
callback(self, message) await callback(message)
async def write(self, message: Instruction) -> None:
"""Write message to the receiver."""
assert self.receiver is not None
await self.receiver.write(message)
@dataclass def start_unloading(self) -> None:
class ReceiverInfo: """Start unloading."""
"""Onkyo receiver information.""" self.callbacks.clear()
host: str
port: int
model_name: str
identifier: str
async def async_interview(host: str) -> ReceiverInfo | None: async def async_interview(host: str) -> ReceiverInfo | None:
"""Interview Onkyo Receiver.""" """Interview the receiver."""
_LOGGER.debug("Interviewing receiver: %s", host) info: ReceiverInfo | None = None
receiver_info: ReceiverInfo | None = None
event = asyncio.Event()
async def _callback(conn: pyeiscp.Connection) -> None:
"""Receiver interviewed, connection not yet active."""
nonlocal receiver_info
if receiver_info is None:
info = ReceiverInfo(host, conn.port, conn.name, conn.identifier)
_LOGGER.debug("Receiver interviewed: %s (%s)", info.model_name, info.host)
receiver_info = info
event.set()
timeout = DEVICE_INTERVIEW_TIMEOUT
await pyeiscp.Connection.discover(
host=host, discovery_callback=_callback, timeout=timeout
)
with contextlib.suppress(asyncio.TimeoutError): with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(event.wait(), timeout) async with asyncio.timeout(DEVICE_INTERVIEW_TIMEOUT):
info = await aioonkyo.interview(host)
return receiver_info return info
async def async_discover() -> Iterable[ReceiverInfo]: async def async_discover(hass: HomeAssistant) -> Iterable[ReceiverInfo]:
"""Discover Onkyo Receivers.""" """Discover receivers."""
_LOGGER.debug("Discovering receivers") all_infos: dict[str, ReceiverInfo] = {}
receiver_infos: list[ReceiverInfo] = [] async def collect_infos(address: str) -> None:
with contextlib.suppress(asyncio.TimeoutError):
async with asyncio.timeout(DEVICE_DISCOVERY_TIMEOUT):
async for info in aioonkyo.discover(address):
all_infos.setdefault(info.identifier, info)
async def _callback(conn: pyeiscp.Connection) -> None: broadcast_addrs = await network.async_get_ipv4_broadcast_addresses(hass)
"""Receiver discovered, connection not yet active.""" tasks = [collect_infos(str(address)) for address in broadcast_addrs]
info = ReceiverInfo(conn.host, conn.port, conn.name, conn.identifier)
_LOGGER.debug("Receiver discovered: %s (%s)", info.model_name, info.host)
receiver_infos.append(info)
timeout = DEVICE_DISCOVERY_TIMEOUT await asyncio.gather(*tasks)
await pyeiscp.Connection.discover(discovery_callback=_callback, timeout=timeout) return all_infos.values()
await asyncio.sleep(timeout)
return receiver_infos

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from aioonkyo import Zone
import voluptuous as vol import voluptuous as vol
from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN
@ -12,29 +13,18 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
from homeassistant.util.hass_dict import HassKey from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN from .const import DOMAIN, LEGACY_REV_HDMI_OUTPUT_MAPPING
if TYPE_CHECKING: if TYPE_CHECKING:
from .media_player import OnkyoMediaPlayer from .media_player import OnkyoMediaPlayer
DATA_MP_ENTITIES: HassKey[dict[str, dict[str, OnkyoMediaPlayer]]] = HassKey(DOMAIN) DATA_MP_ENTITIES: HassKey[dict[str, dict[Zone, OnkyoMediaPlayer]]] = HassKey(DOMAIN)
ATTR_HDMI_OUTPUT = "hdmi_output" ATTR_HDMI_OUTPUT = "hdmi_output"
ACCEPTED_VALUES = [
"no",
"analog",
"yes",
"out",
"out-sub",
"sub",
"hdbaset",
"both",
"up",
]
ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema( ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema(
{ {
vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES), vol.Required(ATTR_HDMI_OUTPUT): vol.In(LEGACY_REV_HDMI_OUTPUT_MAPPING),
} }
) )
SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output" SERVICE_SELECT_HDMI_OUTPUT = "onkyo_select_hdmi_output"

View File

@ -0,0 +1,8 @@
"""Utils for Onkyo."""
from .const import InputSource, ListeningMode
def get_meaning(param: InputSource | ListeningMode) -> str:
"""Get param meaning."""
return " ··· ".join(param.meanings)

6
requirements_all.txt generated
View File

@ -330,6 +330,9 @@ aiontfy==0.5.3
# homeassistant.components.nut # homeassistant.components.nut
aionut==4.3.4 aionut==4.3.4
# homeassistant.components.onkyo
aioonkyo==0.2.0
# homeassistant.components.openexchangerates # homeassistant.components.openexchangerates
aioopenexchangerates==0.6.8 aioopenexchangerates==0.6.8
@ -1956,9 +1959,6 @@ pyefergy==22.5.0
# homeassistant.components.energenie_power_sockets # homeassistant.components.energenie_power_sockets
pyegps==0.2.5 pyegps==0.2.5
# homeassistant.components.onkyo
pyeiscp==0.0.7
# homeassistant.components.emoncms # homeassistant.components.emoncms
pyemoncms==0.1.1 pyemoncms==0.1.1

View File

@ -312,6 +312,9 @@ aiontfy==0.5.3
# homeassistant.components.nut # homeassistant.components.nut
aionut==4.3.4 aionut==4.3.4
# homeassistant.components.onkyo
aioonkyo==0.2.0
# homeassistant.components.openexchangerates # homeassistant.components.openexchangerates
aioopenexchangerates==0.6.8 aioopenexchangerates==0.6.8
@ -1631,9 +1634,6 @@ pyefergy==22.5.0
# homeassistant.components.energenie_power_sockets # homeassistant.components.energenie_power_sockets
pyegps==0.2.5 pyegps==0.2.5
# homeassistant.components.onkyo
pyeiscp==0.0.7
# homeassistant.components.emoncms # homeassistant.components.emoncms
pyemoncms==0.1.1 pyemoncms==0.1.1

View File

@ -1,90 +1,71 @@
"""Tests for the Onkyo integration.""" """Tests for the Onkyo integration."""
from unittest.mock import AsyncMock, Mock, patch from collections.abc import Generator, Iterable
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
from aioonkyo import ReceiverInfo
from homeassistant.components.onkyo.receiver import Receiver, ReceiverInfo
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
RECEIVER_INFO = ReceiverInfo(
host="192.168.0.101",
ip="192.168.0.101",
model_name="TX-NR7100",
identifier="0009B0123456",
)
def create_receiver_info(id: int) -> ReceiverInfo: RECEIVER_INFO_2 = ReceiverInfo(
"""Create an empty receiver info object for testing.""" host="192.168.0.102",
return ReceiverInfo( ip="192.168.0.102",
host=f"host {id}", model_name="TX-RZ50",
port=id, identifier="0009B0ABCDEF",
model_name=f"type {id}", )
identifier=f"id{id}",
)
def create_connection(id: int) -> Mock: @contextmanager
"""Create an mock connection object for testing.""" def mock_discovery(receiver_infos: Iterable[ReceiverInfo] | None) -> Generator[None]:
connection = Mock() """Mock discovery functions."""
connection.host = f"host {id}"
connection.port = 0
connection.name = f"type {id}"
connection.identifier = f"id{id}"
return connection
async def get_info(host: str) -> ReceiverInfo | None:
"""Get receiver info by host."""
for info in receiver_infos:
if info.host == host:
return info
return None
def create_config_entry_from_info(info: ReceiverInfo) -> MockConfigEntry: def get_infos(host: str) -> MagicMock:
"""Create a config entry from receiver info.""" """Get receiver infos from broadcast."""
data = {CONF_HOST: info.host} discover_mock = MagicMock()
options = { discover_mock.__aiter__.return_value = receiver_infos
"volume_resolution": 80, return discover_mock
"max_volume": 100,
"input_sources": {"12": "tv"},
"listening_modes": {"00": "stereo"},
}
return MockConfigEntry( discover_kwargs = {}
data=data, interview_kwargs = {}
options=options, if receiver_infos is None:
title=info.model_name, discover_kwargs["side_effect"] = OSError
domain="onkyo", interview_kwargs["side_effect"] = OSError
unique_id=info.identifier, else:
) discover_kwargs["new"] = get_infos
interview_kwargs["new"] = get_info
def create_empty_config_entry() -> MockConfigEntry:
"""Create an empty config entry for use in unit tests."""
data = {CONF_HOST: ""}
options = {
"volume_resolution": 80,
"max_volume": 100,
"input_sources": {"12": "tv"},
"listening_modes": {"00": "stereo"},
}
return MockConfigEntry(
data=data,
options=options,
title="Unit test Onkyo",
domain="onkyo",
unique_id="onkyo_unique_id",
)
async def setup_integration(
hass: HomeAssistant, config_entry: MockConfigEntry, receiver_info: ReceiverInfo
) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)
mock_receiver = AsyncMock()
mock_receiver.conn.close = Mock()
mock_receiver.callbacks.connect = Mock()
mock_receiver.callbacks.update = Mock()
with ( with (
patch( patch(
"homeassistant.components.onkyo.async_interview", "homeassistant.components.onkyo.receiver.aioonkyo.discover",
return_value=receiver_info, **discover_kwargs,
),
patch(
"homeassistant.components.onkyo.receiver.aioonkyo.interview",
**interview_kwargs,
), ),
patch.object(Receiver, "async_create", return_value=mock_receiver),
): ):
await hass.config_entries.async_setup(config_entry.entry_id) yield
await hass.async_block_till_done()
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the component."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@ -1,74 +1,181 @@
"""Configure tests for the Onkyo integration.""" """Common fixtures for the Onkyo tests."""
from unittest.mock import patch import asyncio
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from aioonkyo import Code, Instruction, Kind, Receiver, Status, Zone, status
import pytest import pytest
from homeassistant.components.onkyo.const import DOMAIN from homeassistant.components.onkyo.const import DOMAIN
from homeassistant.const import CONF_HOST
from . import create_connection from . import RECEIVER_INFO, mock_discovery
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.fixture(name="config_entry") @pytest.fixture(autouse=True)
def mock_default_discovery() -> Generator[None]:
"""Mock the discovery functions with default info."""
with (
patch.multiple(
"homeassistant.components.onkyo.receiver",
DEVICE_INTERVIEW_TIMEOUT=1,
DEVICE_DISCOVERY_TIMEOUT=1,
),
mock_discovery([RECEIVER_INFO]),
):
yield
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Mock integration setup."""
with patch(
"homeassistant.components.onkyo.async_setup_entry",
return_value=True,
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_connect() -> Generator[AsyncMock]:
"""Mock an Onkyo connect."""
with patch(
"homeassistant.components.onkyo.receiver.connect",
) as connect:
yield connect.return_value.__aenter__
INITIAL_MESSAGES = [
status.Power(
Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON
),
status.Power(
Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON
),
status.Power(
Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY
),
status.Power(
Code.from_kind_zone(Kind.POWER, Zone.MAIN), None, status.Power.Param.ON
),
status.Power(
Code.from_kind_zone(Kind.POWER, Zone.ZONE2), None, status.Power.Param.ON
),
status.Power(
Code.from_kind_zone(Kind.POWER, Zone.ZONE3), None, status.Power.Param.STANDBY
),
status.Volume(Code.from_kind_zone(Kind.VOLUME, Zone.ZONE2), None, 50),
status.Muting(
Code.from_kind_zone(Kind.MUTING, Zone.MAIN), None, status.Muting.Param.OFF
),
status.InputSource(
Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.MAIN),
None,
status.InputSource.Param("24"),
),
status.InputSource(
Code.from_kind_zone(Kind.INPUT_SOURCE, Zone.ZONE2),
None,
status.InputSource.Param("00"),
),
status.ListeningMode(
Code.from_kind_zone(Kind.LISTENING_MODE, Zone.MAIN),
None,
status.ListeningMode.Param("01"),
),
status.ListeningMode(
Code.from_kind_zone(Kind.LISTENING_MODE, Zone.ZONE2),
None,
status.ListeningMode.Param("00"),
),
status.HDMIOutput(
Code.from_kind_zone(Kind.HDMI_OUTPUT, Zone.MAIN),
None,
status.HDMIOutput.Param.MAIN,
),
status.TunerPreset(Code.from_kind_zone(Kind.TUNER_PRESET, Zone.MAIN), None, 1),
status.AudioInformation(
Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN),
None,
auto_phase_control_phase="Normal",
),
status.VideoInformation(
Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN),
None,
input_color_depth="24bit",
),
status.FLDisplay(Code.from_kind_zone(Kind.FL_DISPLAY, Zone.MAIN), None, "LALALA"),
status.NotAvailable(
Code.from_kind_zone(Kind.AUDIO_INFORMATION, Zone.MAIN),
None,
Kind.AUDIO_INFORMATION,
),
status.NotAvailable(
Code.from_kind_zone(Kind.VIDEO_INFORMATION, Zone.MAIN),
None,
Kind.VIDEO_INFORMATION,
),
status.Raw(None, None),
]
@pytest.fixture
def read_queue() -> asyncio.Queue[Status | None]:
"""Read messages queue."""
return asyncio.Queue()
@pytest.fixture
def writes() -> list[Instruction]:
"""Written messages."""
return []
@pytest.fixture
def mock_receiver(
mock_connect: AsyncMock,
read_queue: asyncio.Queue[Status | None],
writes: list[Instruction],
) -> AsyncMock:
"""Mock an Onkyo receiver."""
receiver_class = AsyncMock(Receiver, auto_spec=True)
receiver = receiver_class.return_value
for message in INITIAL_MESSAGES:
read_queue.put_nowait(message)
async def read() -> Status:
return await read_queue.get()
async def write(message: Instruction) -> None:
writes.append(message)
receiver.read = read
receiver.write = write
mock_connect.return_value = receiver
return receiver
@pytest.fixture
def mock_config_entry() -> MockConfigEntry: def mock_config_entry() -> MockConfigEntry:
"""Create Onkyo entry in Home Assistant.""" """Mock a config entry."""
data = {CONF_HOST: RECEIVER_INFO.host}
options = {
"volume_resolution": 80,
"max_volume": 100,
"input_sources": {"12": "TV", "24": "FM Radio"},
"listening_modes": {"00": "Stereo", "04": "THX"},
}
return MockConfigEntry( return MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
title="Onkyo", title=RECEIVER_INFO.model_name,
data={}, unique_id=RECEIVER_INFO.identifier,
data=data,
options=options,
) )
@pytest.fixture(autouse=True)
def patch_timeouts():
"""Patch timeouts to avoid tests waiting."""
with patch.multiple(
"homeassistant.components.onkyo.receiver",
DEVICE_INTERVIEW_TIMEOUT=0,
DEVICE_DISCOVERY_TIMEOUT=0,
):
yield
@pytest.fixture
async def default_mock_discovery():
"""Mock discovery with a single device."""
async def mock_discover(host=None, discovery_callback=None, timeout=0):
await discovery_callback(create_connection(1))
with patch(
"homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover",
new=mock_discover,
):
yield
@pytest.fixture
async def stub_mock_discovery():
"""Mock discovery with no devices."""
async def mock_discover(host=None, discovery_callback=None, timeout=0):
pass
with patch(
"homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover",
new=mock_discover,
):
yield
@pytest.fixture
async def empty_mock_discovery():
"""Mock discovery with an empty connection."""
async def mock_discover(host=None, discovery_callback=None, timeout=0):
await discovery_callback(None)
with patch(
"homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover",
new=mock_discover,
):
yield

View File

@ -0,0 +1,203 @@
# serializer version: 1
# name: test_entities[media_player.tx_nr7100-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'sound_mode_list': list([
'Stereo',
'THX',
]),
'source_list': list([
'TV',
'FM Radio',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.tx_nr7100',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'TX-NR7100',
'platform': 'onkyo',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 69516>,
'translation_key': None,
'unique_id': '0009B0123456_main',
'unit_of_measurement': None,
})
# ---
# name: test_entities[media_player.tx_nr7100-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'audio_information': dict({
'auto_phase_control_phase': 'Normal',
}),
'friendly_name': 'TX-NR7100',
'is_volume_muted': False,
'preset': 1,
'sound_mode': 'DIRECT',
'sound_mode_list': list([
'Stereo',
'THX',
]),
'source': 'FM Radio',
'source_list': list([
'TV',
'FM Radio',
]),
'supported_features': <MediaPlayerEntityFeature: 69516>,
'video_information': dict({
'input_color_depth': '24bit',
}),
'video_out': 'yes,out',
}),
'context': <ANY>,
'entity_id': 'media_player.tx_nr7100',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities[media_player.tx_nr7100_zone_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'sound_mode_list': list([
'Stereo',
]),
'source_list': list([
'TV',
'FM Radio',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.tx_nr7100_zone_2',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'TX-NR7100 Zone 2',
'platform': 'onkyo',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 69516>,
'translation_key': None,
'unique_id': '0009B0123456_zone2',
'unit_of_measurement': None,
})
# ---
# name: test_entities[media_player.tx_nr7100_zone_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'TX-NR7100 Zone 2',
'sound_mode': 'Stereo',
'sound_mode_list': list([
'Stereo',
]),
'source': 'VIDEO1 ··· VCR/DVR ··· STB/DVR',
'source_list': list([
'TV',
'FM Radio',
]),
'supported_features': <MediaPlayerEntityFeature: 69516>,
'volume_level': 0.625,
}),
'context': <ANY>,
'entity_id': 'media_player.tx_nr7100_zone_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_entities[media_player.tx_nr7100_zone_3-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'source_list': list([
'TV',
'FM Radio',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.tx_nr7100_zone_3',
'has_entity_name': False,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'TX-NR7100 Zone 3',
'platform': 'onkyo',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 2944>,
'translation_key': None,
'unique_id': '0009B0123456_zone3',
'unit_of_measurement': None,
})
# ---
# name: test_entities[media_player.tx_nr7100_zone_3-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'TX-NR7100 Zone 3',
'source_list': list([
'TV',
'FM Radio',
]),
'supported_features': <MediaPlayerEntityFeature: 2944>,
}),
'context': <ANY>,
'entity_id': 'media_player.tx_nr7100_zone_3',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

View File

@ -1,11 +1,9 @@
"""Test Onkyo config flow.""" """Test Onkyo config flow."""
from unittest.mock import patch from aioonkyo import ReceiverInfo
import pytest import pytest
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow
from homeassistant.components.onkyo.const import ( from homeassistant.components.onkyo.const import (
DOMAIN, DOMAIN,
OPTION_INPUT_SOURCES, OPTION_INPUT_SOURCES,
@ -23,17 +21,15 @@ from homeassistant.helpers.service_info.ssdp import (
SsdpServiceInfo, SsdpServiceInfo,
) )
from . import ( from . import RECEIVER_INFO, RECEIVER_INFO_2, mock_discovery, setup_integration
create_config_entry_from_info,
create_connection,
create_empty_config_entry,
create_receiver_info,
setup_integration,
)
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
def _entry_title(receiver_info: ReceiverInfo) -> str:
return f"{receiver_info.model_name} ({receiver_info.host})"
async def test_user_initial_menu(hass: HomeAssistant) -> None: async def test_user_initial_menu(hass: HomeAssistant) -> None:
"""Test initial menu.""" """Test initial menu."""
init_result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
@ -46,7 +42,7 @@ async def test_user_initial_menu(hass: HomeAssistant) -> None:
assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"} assert not set(init_result["menu_options"]) ^ {"manual", "eiscp_discovery"}
async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) -> None: async def test_manual_valid_host(hass: HomeAssistant) -> None:
"""Test valid host entered.""" """Test valid host entered."""
init_result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
@ -60,14 +56,16 @@ async def test_manual_valid_host(hass: HomeAssistant, default_mock_discovery) ->
select_result = await hass.config_entries.flow.async_configure( select_result = await hass.config_entries.flow.async_configure(
form_result["flow_id"], form_result["flow_id"],
user_input={CONF_HOST: "host 1"}, user_input={CONF_HOST: RECEIVER_INFO.host},
) )
assert select_result["step_id"] == "configure_receiver" assert select_result["step_id"] == "configure_receiver"
assert select_result["description_placeholders"]["name"] == "type 1 (host 1)" assert select_result["description_placeholders"]["name"] == _entry_title(
RECEIVER_INFO
)
async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> None: async def test_manual_invalid_host(hass: HomeAssistant) -> None:
"""Test invalid host entered.""" """Test invalid host entered."""
init_result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
@ -79,18 +77,17 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) ->
{"next_step_id": "manual"}, {"next_step_id": "manual"},
) )
host_result = await hass.config_entries.flow.async_configure( with mock_discovery([]):
form_result["flow_id"], host_result = await hass.config_entries.flow.async_configure(
user_input={CONF_HOST: "sample-host-name"}, form_result["flow_id"],
) user_input={CONF_HOST: "sample-host-name"},
)
assert host_result["step_id"] == "manual" assert host_result["step_id"] == "manual"
assert host_result["errors"]["base"] == "cannot_connect" assert host_result["errors"]["base"] == "cannot_connect"
async def test_manual_valid_host_unexpected_error( async def test_manual_valid_host_unexpected_error(hass: HomeAssistant) -> None:
hass: HomeAssistant, empty_mock_discovery
) -> None:
"""Test valid host entered.""" """Test valid host entered."""
init_result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
@ -103,112 +100,102 @@ async def test_manual_valid_host_unexpected_error(
{"next_step_id": "manual"}, {"next_step_id": "manual"},
) )
host_result = await hass.config_entries.flow.async_configure( with mock_discovery(None):
form_result["flow_id"], host_result = await hass.config_entries.flow.async_configure(
user_input={CONF_HOST: "sample-host-name"}, form_result["flow_id"],
) user_input={CONF_HOST: "sample-host-name"},
)
assert host_result["step_id"] == "manual" assert host_result["step_id"] == "manual"
assert host_result["errors"]["base"] == "unknown" assert host_result["errors"]["base"] == "unknown"
async def test_discovery_and_no_devices_discovered( async def test_eiscp_discovery_no_devices_found(hass: HomeAssistant) -> None:
hass: HomeAssistant, stub_mock_discovery """Test eiscp discovery with no devices found."""
) -> None: result = await hass.config_entries.flow.async_init(
"""Test initial menu."""
init_result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_USER}, context={"source": SOURCE_USER},
) )
form_result = await hass.config_entries.flow.async_configure( with mock_discovery([]):
init_result["flow_id"], result = await hass.config_entries.flow.async_configure(
{"next_step_id": "eiscp_discovery"}, result["flow_id"],
)
assert form_result["type"] is FlowResultType.ABORT
assert form_result["reason"] == "no_devices_found"
async def test_discovery_with_exception(
hass: HomeAssistant, empty_mock_discovery
) -> None:
"""Test discovery which throws an unexpected exception."""
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": "eiscp_discovery"},
)
assert form_result["type"] is FlowResultType.ABORT
assert form_result["reason"] == "unknown"
async def test_discovery_with_new_and_existing_found(hass: HomeAssistant) -> None:
"""Test discovery with a new and an existing entry."""
init_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
async def mock_discover(discovery_callback, timeout):
await discovery_callback(create_connection(1))
await discovery_callback(create_connection(2))
with (
patch("pyeiscp.Connection.discover", new=mock_discover),
# Fake it like the first entry was already added
patch.object(OnkyoConfigFlow, "_async_current_ids", return_value=["id1"]),
):
form_result = await hass.config_entries.flow.async_configure(
init_result["flow_id"],
{"next_step_id": "eiscp_discovery"}, {"next_step_id": "eiscp_discovery"},
) )
assert form_result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"
assert form_result["data_schema"] is not None
schema = form_result["data_schema"].schema async def test_eiscp_discovery_unexpected_exception(hass: HomeAssistant) -> None:
"""Test eiscp discovery with an unexpected exception."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
with mock_discovery(None):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "eiscp_discovery"},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "unknown"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_eiscp_discovery(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test eiscp discovery."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
)
with mock_discovery([RECEIVER_INFO, RECEIVER_INFO_2]):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"next_step_id": "eiscp_discovery"},
)
assert result["type"] is FlowResultType.FORM
assert result["data_schema"] is not None
schema = result["data_schema"].schema
container = schema["device"].container container = schema["device"].container
assert container == {"id2": "type 2 (host 2)"} assert container == {RECEIVER_INFO_2.identifier: _entry_title(RECEIVER_INFO_2)}
result = await hass.config_entries.flow.async_configure(
async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: result["flow_id"],
"""Test discovery after a selection.""" user_input={"device": RECEIVER_INFO_2.identifier},
init_result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
) )
async def mock_discover(discovery_callback, timeout): assert result["step_id"] == "configure_receiver"
await discovery_callback(create_connection(42))
await discovery_callback(create_connection(0))
with patch("pyeiscp.Connection.discover", new=mock_discover): result = await hass.config_entries.flow.async_configure(
form_result = await hass.config_entries.flow.async_configure( result["flow_id"],
init_result["flow_id"], user_input={
{"next_step_id": "eiscp_discovery"}, "volume_resolution": 200,
) "input_sources": ["TV"],
"listening_modes": ["THX"],
},
)
select_result = await hass.config_entries.flow.async_configure( assert result["type"] is FlowResultType.CREATE_ENTRY
form_result["flow_id"], assert result["data"]["host"] == RECEIVER_INFO_2.host
user_input={"device": "id42"}, assert result["result"].unique_id == RECEIVER_INFO_2.identifier
)
assert select_result["step_id"] == "configure_receiver"
assert select_result["description_placeholders"]["name"] == "type 42 (host 42)"
async def test_ssdp_discovery_success( @pytest.mark.usefixtures("mock_setup_entry")
hass: HomeAssistant, default_mock_discovery async def test_ssdp_discovery_success(hass: HomeAssistant) -> None:
) -> None:
"""Test SSDP discovery with valid host.""" """Test SSDP discovery with valid host."""
discovery_info = SsdpServiceInfo( discovery_info = SsdpServiceInfo(
ssdp_location="http://192.168.1.100:8080", ssdp_location="http://192.168.0.101:8080",
upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"},
ssdp_usn="uuid:mock_usn", ssdp_usn="uuid:mock_usn",
ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000",
@ -224,7 +211,7 @@ async def test_ssdp_discovery_success(
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "configure_receiver" assert result["step_id"] == "configure_receiver"
select_result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={ user_input={
"volume_resolution": 200, "volume_resolution": 200,
@ -233,24 +220,19 @@ async def test_ssdp_discovery_success(
}, },
) )
assert select_result["type"] is FlowResultType.CREATE_ENTRY assert result["type"] is FlowResultType.CREATE_ENTRY
assert select_result["data"]["host"] == "192.168.1.100" assert result["data"]["host"] == RECEIVER_INFO.host
assert select_result["result"].unique_id == "id1" assert result["result"].unique_id == RECEIVER_INFO.identifier
async def test_ssdp_discovery_already_configured( async def test_ssdp_discovery_already_configured(
hass: HomeAssistant, default_mock_discovery hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None: ) -> None:
"""Test SSDP discovery with already configured device.""" """Test SSDP discovery with already configured device."""
config_entry = MockConfigEntry( mock_config_entry.add_to_hass(hass)
domain=DOMAIN,
data={CONF_HOST: "192.168.1.100"},
unique_id="id1",
)
config_entry.add_to_hass(hass)
discovery_info = SsdpServiceInfo( discovery_info = SsdpServiceInfo(
ssdp_location="http://192.168.1.100:8080", ssdp_location="http://192.168.0.101:8080",
upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, upnp={ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"},
ssdp_usn="uuid:mock_usn", ssdp_usn="uuid:mock_usn",
ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", ssdp_udn="uuid:00000000-0000-0000-0000-000000000000",
@ -276,10 +258,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None:
ssdp_st="mock_st", ssdp_st="mock_st",
) )
with patch( with mock_discovery(None):
"homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover",
side_effect=OSError,
):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": config_entries.SOURCE_SSDP}, context={"source": config_entries.SOURCE_SSDP},
@ -290,9 +269,7 @@ async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None:
assert result["reason"] == "unknown" assert result["reason"] == "unknown"
async def test_ssdp_discovery_host_none_info( async def test_ssdp_discovery_host_none_info(hass: HomeAssistant) -> None:
hass: HomeAssistant, stub_mock_discovery
) -> None:
"""Test SSDP discovery with host info error.""" """Test SSDP discovery with host info error."""
discovery_info = SsdpServiceInfo( discovery_info = SsdpServiceInfo(
ssdp_location="http://192.168.1.100:8080", ssdp_location="http://192.168.1.100:8080",
@ -301,19 +278,18 @@ async def test_ssdp_discovery_host_none_info(
ssdp_st="mock_st", ssdp_st="mock_st",
) )
result = await hass.config_entries.flow.async_init( with mock_discovery([]):
DOMAIN, result = await hass.config_entries.flow.async_init(
context={"source": config_entries.SOURCE_SSDP}, DOMAIN,
data=discovery_info, context={"source": config_entries.SOURCE_SSDP},
) data=discovery_info,
)
assert result["type"] is FlowResultType.ABORT assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect" assert result["reason"] == "cannot_connect"
async def test_ssdp_discovery_no_location( async def test_ssdp_discovery_no_location(hass: HomeAssistant) -> None:
hass: HomeAssistant, default_mock_discovery
) -> None:
"""Test SSDP discovery with no location.""" """Test SSDP discovery with no location."""
discovery_info = SsdpServiceInfo( discovery_info = SsdpServiceInfo(
ssdp_location=None, ssdp_location=None,
@ -332,9 +308,7 @@ async def test_ssdp_discovery_no_location(
assert result["reason"] == "unknown" assert result["reason"] == "unknown"
async def test_ssdp_discovery_no_host( async def test_ssdp_discovery_no_host(hass: HomeAssistant) -> None:
hass: HomeAssistant, default_mock_discovery
) -> None:
"""Test SSDP discovery with no host.""" """Test SSDP discovery with no host."""
discovery_info = SsdpServiceInfo( discovery_info = SsdpServiceInfo(
ssdp_location="http://", ssdp_location="http://",
@ -353,9 +327,7 @@ async def test_ssdp_discovery_no_host(
assert result["reason"] == "unknown" assert result["reason"] == "unknown"
async def test_configure_no_resolution( async def test_configure_no_resolution(hass: HomeAssistant) -> None:
hass: HomeAssistant, default_mock_discovery
) -> None:
"""Test receiver configure with no resolution set.""" """Test receiver configure with no resolution set."""
init_result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
@ -380,9 +352,9 @@ async def test_configure_no_resolution(
) )
async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None: @pytest.mark.usefixtures("mock_setup_entry")
async def test_configure(hass: HomeAssistant) -> None:
"""Test receiver configure.""" """Test receiver configure."""
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, DOMAIN,
context={"source": SOURCE_USER}, context={"source": SOURCE_USER},
@ -395,7 +367,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None:
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
result["flow_id"], result["flow_id"],
user_input={CONF_HOST: "sample-host-name"}, user_input={CONF_HOST: RECEIVER_INFO.host},
) )
result = await hass.config_entries.flow.async_configure( result = await hass.config_entries.flow.async_configure(
@ -437,9 +409,7 @@ async def test_configure(hass: HomeAssistant, default_mock_discovery) -> None:
} }
async def test_configure_invalid_resolution_set( async def test_configure_invalid_resolution_set(hass: HomeAssistant) -> None:
hass: HomeAssistant, default_mock_discovery
) -> None:
"""Test receiver configure with invalid resolution.""" """Test receiver configure with invalid resolution."""
init_result = await hass.config_entries.flow.async_init( init_result = await hass.config_entries.flow.async_init(
@ -464,22 +434,23 @@ async def test_configure_invalid_resolution_set(
) )
async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None: @pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the reconfigure config flow.""" """Test the reconfigure config flow."""
receiver_info = create_receiver_info(1) await setup_integration(hass, mock_config_entry)
config_entry = create_config_entry_from_info(receiver_info)
await setup_integration(hass, config_entry, receiver_info)
old_host = config_entry.data[CONF_HOST] old_host = mock_config_entry.data[CONF_HOST]
old_options = config_entry.options old_options = mock_config_entry.options
result = await config_entry.start_reconfigure_flow(hass) result = await mock_config_entry.start_reconfigure_flow(hass)
assert result["type"] is FlowResultType.FORM assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "manual" assert result["step_id"] == "manual"
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"host": receiver_info.host} result["flow_id"], user_input={"host": mock_config_entry.data[CONF_HOST]}
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -494,36 +465,28 @@ async def test_reconfigure(hass: HomeAssistant, default_mock_discovery) -> None:
assert result3["type"] is FlowResultType.ABORT assert result3["type"] is FlowResultType.ABORT
assert result3["reason"] == "reconfigure_successful" assert result3["reason"] == "reconfigure_successful"
assert config_entry.data[CONF_HOST] == old_host assert mock_config_entry.data[CONF_HOST] == old_host
assert config_entry.options[OPTION_VOLUME_RESOLUTION] == 200 assert mock_config_entry.options[OPTION_VOLUME_RESOLUTION] == 200
for option, option_value in old_options.items(): for option, option_value in old_options.items():
if option == OPTION_VOLUME_RESOLUTION: if option == OPTION_VOLUME_RESOLUTION:
continue continue
assert config_entry.options[option] == option_value assert mock_config_entry.options[option] == option_value
async def test_reconfigure_new_device(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_setup_entry")
async def test_reconfigure_new_device(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test the reconfigure config flow with new device.""" """Test the reconfigure config flow with new device."""
receiver_info = create_receiver_info(1) await setup_integration(hass, mock_config_entry)
config_entry = create_config_entry_from_info(receiver_info)
await setup_integration(hass, config_entry, receiver_info)
old_unique_id = receiver_info.identifier old_unique_id = mock_config_entry.unique_id
result = await config_entry.start_reconfigure_flow(hass) result = await mock_config_entry.start_reconfigure_flow(hass)
mock_connection = create_connection(2) with mock_discovery([RECEIVER_INFO_2]):
# Create mock discover that calls callback immediately
async def mock_discover(host, discovery_callback, timeout):
await discovery_callback(mock_connection)
with patch(
"homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover",
new=mock_discover,
):
result2 = await hass.config_entries.flow.async_configure( result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={"host": mock_connection.host} result["flow_id"], user_input={CONF_HOST: RECEIVER_INFO_2.host}
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -531,9 +494,10 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None:
assert result2["reason"] == "unique_id_mismatch" assert result2["reason"] == "unique_id_mismatch"
# unique id should remain unchanged # unique id should remain unchanged
assert config_entry.unique_id == old_unique_id assert mock_config_entry.unique_id == old_unique_id
@pytest.mark.usefixtures("mock_setup_entry")
@pytest.mark.parametrize( @pytest.mark.parametrize(
"ignore_missing_translations", "ignore_missing_translations",
[ [
@ -545,16 +509,15 @@ async def test_reconfigure_new_device(hass: HomeAssistant) -> None:
] ]
], ],
) )
async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: async def test_options_flow(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test options flow.""" """Test options flow."""
await setup_integration(hass, mock_config_entry)
receiver_info = create_receiver_info(1) old_volume_resolution = mock_config_entry.options[OPTION_VOLUME_RESOLUTION]
config_entry = create_empty_config_entry()
await setup_integration(hass, config_entry, receiver_info)
old_volume_resolution = config_entry.options[OPTION_VOLUME_RESOLUTION] result = await hass.config_entries.options.async_init(mock_config_entry.entry_id)
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure( result = await hass.config_entries.options.async_configure(
result["flow_id"], result["flow_id"],

View File

@ -2,51 +2,85 @@
from __future__ import annotations from __future__ import annotations
from unittest.mock import patch import asyncio
from unittest.mock import AsyncMock
from aioonkyo import Status
import pytest import pytest
from homeassistant.components.onkyo import async_setup_entry
from homeassistant.config_entries import ConfigEntryState from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from . import create_empty_config_entry, create_receiver_info, setup_integration from . import mock_discovery, setup_integration
from tests.common import MockConfigEntry from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_receiver")
async def test_load_unload_entry( async def test_load_unload_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
) -> None: ) -> None:
"""Test load and unload entry.""" """Test load and unload entry."""
await setup_integration(hass, mock_config_entry)
config_entry = create_empty_config_entry() assert mock_config_entry.state is ConfigEntryState.LOADED
receiver_info = create_receiver_info(1)
await setup_integration(hass, config_entry, receiver_info)
assert config_entry.state is ConfigEntryState.LOADED await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.config_entries.async_unload(config_entry.entry_id)
await hass.async_block_till_done() await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.NOT_LOADED
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_no_connection( @pytest.mark.parametrize(
"receiver_infos",
[
None,
[],
],
)
async def test_initialization_failure(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
receiver_infos,
) -> None: ) -> None:
"""Test update options.""" """Test initialization failure."""
with mock_discovery(receiver_infos):
await setup_integration(hass, mock_config_entry)
config_entry = create_empty_config_entry() assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
config_entry.add_to_hass(hass)
with (
patch( async def test_connection_failure(
"homeassistant.components.onkyo.async_interview", hass: HomeAssistant,
return_value=None, mock_config_entry: MockConfigEntry,
), mock_connect: AsyncMock,
pytest.raises(ConfigEntryNotReady), ) -> None:
): """Test connection failure."""
await async_setup_entry(hass, config_entry) mock_connect.side_effect = OSError
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
@pytest.mark.usefixtures("mock_receiver")
async def test_reconnect(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_connect: AsyncMock,
read_queue: asyncio.Queue[Status | None],
) -> None:
"""Test reconnect."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_connect.reset_mock()
assert mock_connect.call_count == 0
read_queue.put_nowait(None) # Simulate a disconnect
await asyncio.sleep(0)
assert mock_connect.call_count == 1

View File

@ -0,0 +1,230 @@
"""Test Onkyo media player platform."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, patch
from aioonkyo import Instruction, Zone, command
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.media_player import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_VOLUME_LEVEL,
ATTR_MEDIA_VOLUME_MUTED,
ATTR_SOUND_MODE,
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOUND_MODE,
SERVICE_SELECT_SOURCE,
)
from homeassistant.components.onkyo.services import (
ATTR_HDMI_OUTPUT,
SERVICE_SELECT_HDMI_OUTPUT,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, snapshot_platform
ENTITY_ID = "media_player.tx_nr7100"
ENTITY_ID_ZONE_2 = "media_player.tx_nr7100_zone_2"
ENTITY_ID_ZONE_3 = "media_player.tx_nr7100_zone_3"
@pytest.fixture(autouse=True)
async def auto_setup_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_receiver: AsyncMock,
writes: list[Instruction],
) -> AsyncGenerator[None]:
"""Auto setup integration."""
with (
patch(
"homeassistant.components.onkyo.media_player.AUDIO_VIDEO_INFORMATION_UPDATE_WAIT_TIME",
0,
),
patch("homeassistant.components.onkyo.PLATFORMS", [Platform.MEDIA_PLAYER]),
):
await setup_integration(hass, mock_config_entry)
writes.clear()
yield
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entities."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("action", "action_data", "message"),
[
(SERVICE_TURN_ON, {}, command.Power(Zone.MAIN, command.Power.Param.ON)),
(SERVICE_TURN_OFF, {}, command.Power(Zone.MAIN, command.Power.Param.STANDBY)),
(
SERVICE_VOLUME_SET,
{ATTR_MEDIA_VOLUME_LEVEL: 0.5},
command.Volume(Zone.MAIN, 40),
),
(SERVICE_VOLUME_UP, {}, command.Volume(Zone.MAIN, command.Volume.Param.UP)),
(SERVICE_VOLUME_DOWN, {}, command.Volume(Zone.MAIN, command.Volume.Param.DOWN)),
(
SERVICE_VOLUME_MUTE,
{ATTR_MEDIA_VOLUME_MUTED: True},
command.Muting(Zone.MAIN, command.Muting.Param.ON),
),
(
SERVICE_VOLUME_MUTE,
{ATTR_MEDIA_VOLUME_MUTED: False},
command.Muting(Zone.MAIN, command.Muting.Param.OFF),
),
],
)
async def test_actions(
hass: HomeAssistant,
writes: list[Instruction],
action: str,
action_data: dict,
message: Instruction,
) -> None:
"""Test actions."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
action,
{ATTR_ENTITY_ID: ENTITY_ID, **action_data},
blocking=True,
)
assert writes[0] == message
async def test_select_source(hass: HomeAssistant, writes: list[Instruction]) -> None:
"""Test select source."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "TV"},
blocking=True,
)
assert writes[0] == command.InputSource(Zone.MAIN, command.InputSource.Param("12"))
writes.clear()
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOURCE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "InvalidSource"},
blocking=True,
)
assert not writes
async def test_select_sound_mode(
hass: HomeAssistant, writes: list[Instruction]
) -> None:
"""Test select sound mode."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOUND_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "THX"},
blocking=True,
)
assert writes[0] == command.ListeningMode(
Zone.MAIN, command.ListeningMode.Param("04")
)
writes.clear()
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_SOUND_MODE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_SOUND_MODE: "InvalidMode"},
blocking=True,
)
assert not writes
async def test_play_media(hass: HomeAssistant, writes: list[Instruction]) -> None:
"""Test play media (radio preset)."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "radio",
ATTR_MEDIA_CONTENT_ID: "5",
},
blocking=True,
)
assert writes[0] == command.TunerPreset(Zone.MAIN, 5)
writes.clear()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID,
ATTR_MEDIA_CONTENT_TYPE: "music",
ATTR_MEDIA_CONTENT_ID: "5",
},
blocking=True,
)
assert not writes
writes.clear()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID_ZONE_2,
ATTR_MEDIA_CONTENT_TYPE: "radio",
ATTR_MEDIA_CONTENT_ID: "5",
},
blocking=True,
)
assert not writes
writes.clear()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: ENTITY_ID_ZONE_3,
ATTR_MEDIA_CONTENT_TYPE: "radio",
ATTR_MEDIA_CONTENT_ID: "5",
},
blocking=True,
)
assert not writes
async def test_select_hdmi_output(
hass: HomeAssistant, writes: list[Instruction]
) -> None:
"""Test select hdmi output."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_SELECT_HDMI_OUTPUT,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_HDMI_OUTPUT: "sub"},
blocking=True,
)
assert writes[0] == command.HDMIOutput(command.HDMIOutput.Param.BOTH)