From 2497b88382cc4762cde1a91253086c49c3e3afbc Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sun, 27 Nov 2022 12:28:26 +0100 Subject: [PATCH 1/6] Update to ynca 4 beta 2 (#86) * Update for initial API changes * Extend tests * Fix soundmode selection and improve tests * Bump ynca version to 4.0.0a1 * Update modelinfo calls * Also update ynca 4.0.0a1 in requirements * Bump ynca to 4.0.0a2 * Update for ynca API changes * Bump ynca to 4.0.0b1 * Update github actions to more recent versions * Bump ynca version to 4.0.0b2 * Remove python 3.11 from workflow as HA not ready yet --- .github/workflows/release.yaml | 2 +- .github/workflows/validations.yaml | 4 +- custom_components/yamaha_ynca/__init__.py | 16 +- custom_components/yamaha_ynca/button.py | 26 +- custom_components/yamaha_ynca/config_flow.py | 32 +- custom_components/yamaha_ynca/const.py | 12 +- custom_components/yamaha_ynca/diagnostics.py | 10 +- custom_components/yamaha_ynca/helpers.py | 2 +- .../yamaha_ynca/input_helpers.py | 131 +++++++ custom_components/yamaha_ynca/manifest.json | 2 +- custom_components/yamaha_ynca/media_player.py | 201 +++++----- requirements.txt | 2 +- tests/conftest.py | 55 ++- tests/test_button.py | 73 +++- tests/test_config_flow.py | 83 ++-- tests/test_diagnostics.py | 6 +- tests/test_helpers.py | 12 - tests/test_init.py | 14 +- tests/test_input_helpers.py | 194 ++++++++++ tests/test_media_player.py | 362 +++++++++--------- 20 files changed, 817 insertions(+), 422 deletions(-) create mode 100644 custom_components/yamaha_ynca/input_helpers.py create mode 100644 tests/test_input_helpers.py diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 84a572b..bb22e52 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Zip custom components dir working-directory: "custom_components" diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index a9d38d2..d126bf8 100644 --- a/.github/workflows/validations.yaml +++ b/.github/workflows/validations.yaml @@ -13,9 +13,9 @@ jobs: matrix: python-version: ["3.9", "3.10"] steps: - - uses: "actions/checkout@v2" + - uses: "actions/checkout@v3" - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/custom_components/yamaha_ynca/__init__.py b/custom_components/yamaha_ynca/__init__.py index e0b3212..f5d80f3 100644 --- a/custom_components/yamaha_ynca/__init__.py +++ b/custom_components/yamaha_ynca/__init__.py @@ -31,9 +31,9 @@ async def update_device_registry( - hass: HomeAssistant, config_entry: ConfigEntry, receiver: ynca.Ynca + hass: HomeAssistant, config_entry: ConfigEntry, receiver: ynca.YncaApi ): - assert receiver.SYS is not None + assert receiver.sys is not None # Configuration URL for devices connected through IP configuration_url = None @@ -49,9 +49,9 @@ async def update_device_registry( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer=MANUFACTURER_NAME, - name=f"{MANUFACTURER_NAME} {receiver.SYS.modelname}", - model=receiver.SYS.modelname, - sw_version=receiver.SYS.version, + name=f"{MANUFACTURER_NAME} {receiver.sys.modelname}", + model=receiver.sys.modelname, + sw_version=receiver.sys.version, configuration_url=configuration_url, ) @@ -182,7 +182,7 @@ async def async_handle_send_raw_ynca(hass: HomeAssistant, call: ServiceCall): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Yamaha (YNCA) from a config entry.""" - def initialize_ynca(ynca_receiver: ynca.Ynca): + def initialize_ynca(ynca_receiver: ynca.YncaApi): try: # Sync function taking a long time (> 10 seconds depending on receiver capabilities) ynca_receiver.initialize() @@ -222,7 +222,7 @@ def on_disconnect(): hass.config_entries.async_update_entry(entry, data=entry.data) - ynca_receiver = ynca.Ynca( + ynca_receiver = ynca.YncaApi( entry.data[CONF_SERIAL_URL], on_disconnect, COMMUNICATION_LOG_SIZE, @@ -254,7 +254,7 @@ async def async_handle_send_raw_ynca_local(call: ServiceCall): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - def close_ynca(ynca_receiver: ynca.Ynca): + def close_ynca(ynca_receiver: ynca.YncaApi): ynca_receiver.close() if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): diff --git a/custom_components/yamaha_ynca/button.py b/custom_components/yamaha_ynca/button.py index 4435061..6a91e0c 100644 --- a/custom_components/yamaha_ynca/button.py +++ b/custom_components/yamaha_ynca/button.py @@ -2,7 +2,7 @@ from homeassistant.components.button import ButtonEntity -from .const import DOMAIN, ZONE_SUBUNIT_IDS +from .const import DOMAIN, ZONE_SUBUNITS from .helpers import DomainEntryData @@ -11,12 +11,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] entities = [] - for zone in ZONE_SUBUNIT_IDS: - if zone_subunit := getattr(domain_entry_data.api, zone): - for scene_id in zone_subunit.scenenames.keys(): - entities.append( - YamahaYncaSceneButton(config_entry.entry_id, zone_subunit, scene_id) - ) + for zone_attr_name in ZONE_SUBUNITS: + if zone_subunit := getattr(domain_entry_data.api, zone_attr_name): + for scene_id in range(1, 12 + 1): + if getattr(zone_subunit, f"scene{scene_id}name"): + entities.append( + YamahaYncaSceneButton( + config_entry.entry_id, zone_subunit, scene_id + ) + ) async_add_entities(entities) @@ -37,8 +40,9 @@ def __init__(self, receiver_unique_id, zone, scene_id): "identifiers": {(DOMAIN, receiver_unique_id)}, } - def update_callback(self): - self.schedule_update_ha_state() + def update_callback(self, function, value): + if function in ["ZONENAME", f"SCENE{self._scene_id}NAME"]: + self.schedule_update_ha_state() async def async_added_to_hass(self): self._zone.register_update_callback(self.update_callback) @@ -48,7 +52,7 @@ async def async_will_remove_from_hass(self): @property def name(self): - return f"{self._zone.zonename}: {self._zone.scenenames[self._scene_id]}" + return f"{self._zone.zonename}: {getattr(self._zone, f'scene{self._scene_id}name', f'Scene {self._scene_id}')}" def press(self) -> None: - self._zone.activate_scene(self._scene_id) + self._zone.scene(self._scene_id) diff --git a/custom_components/yamaha_ynca/config_flow.py b/custom_components/yamaha_ynca/config_flow.py index 6aa2d38..79e7359 100644 --- a/custom_components/yamaha_ynca/config_flow.py +++ b/custom_components/yamaha_ynca/config_flow.py @@ -13,6 +13,8 @@ from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from custom_components.yamaha_ynca.input_helpers import InputHelper + from .const import ( CONF_HIDDEN_INPUTS_FOR_ZONE, CONF_HIDDEN_SOUND_MODES, @@ -20,7 +22,7 @@ CONF_HOST, CONF_PORT, DOMAIN, - ZONE_SUBUNIT_IDS, + ZONE_SUBUNITS, LOGGER, ) from .helpers import DomainEntryData @@ -61,7 +63,7 @@ async def validate_input(hass: HomeAssistant, data: Dict[str, Any]) -> Dict[str, """ def validate_connection(serial_url): - return ynca.Ynca(serial_url).connection_check() + return ynca.YncaApi(serial_url).connection_check() modelname = await hass.async_add_executor_job( validate_connection, data[CONF_SERIAL_URL] @@ -74,6 +76,7 @@ def validate_connection(serial_url): class YamahaYncaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Yamaha (YNCA).""" + # When updating also update the one used in `setup_integration` for tests VERSION = 5 @staticmethod @@ -172,11 +175,13 @@ async def async_step_init(self, user_input=None): self.config_entry.entry_id, None ) api = domain_entry_data.api - modelinfo = ynca.get_modelinfo(api.SYS.modelname) + modelinfo = ynca.YncaModelInfo.get(api.sys.modelname) # Hiding sound modes sound_modes = [] for sound_mode in ynca.SoundPrg: + if sound_mode is ynca.SoundPrg.UNKNOWN: + continue if modelinfo and not sound_mode in modelinfo.soundprg: continue # Skip soundmodes not supported on the model sound_modes.append(sound_mode.value) @@ -199,22 +204,23 @@ async def async_step_init(self, user_input=None): # Hiding inputs per zone inputs = {} - for inputinfo in ynca.get_inputinfo_list(api): - inputs[inputinfo.input] = ( - f"{inputinfo.input} ({inputinfo.name})" - if inputinfo.input != inputinfo.name - else inputinfo.name + for input, name in InputHelper.get_source_mapping(api).items(): + inputs[input.value] = ( + f"{input.value} ({name})" + if input.value.lower() != name.strip().lower() + else name ) + # Sorts the inputs (3.7+ dicts maintain insertion order) - inputs = dict(sorted(inputs.items(), key=lambda tup: tup[0])) + inputs = dict(sorted(inputs.items(), key=lambda item: item[1])) - for zone_id in ZONE_SUBUNIT_IDS: - if getattr(api, zone_id, None): + for zone_attr_name in ZONE_SUBUNITS: + if getattr(api, zone_attr_name, None): schema[ vol.Required( - CONF_HIDDEN_INPUTS_FOR_ZONE(zone_id), + CONF_HIDDEN_INPUTS_FOR_ZONE(zone_attr_name.upper()), default=self.config_entry.options.get( - CONF_HIDDEN_INPUTS_FOR_ZONE(zone_id), [] + CONF_HIDDEN_INPUTS_FOR_ZONE(zone_attr_name.upper()), [] ), ) ] = cv.multi_select(inputs) diff --git a/custom_components/yamaha_ynca/const.py b/custom_components/yamaha_ynca/const.py index f034e5c..c72400f 100644 --- a/custom_components/yamaha_ynca/const.py +++ b/custom_components/yamaha_ynca/const.py @@ -1,7 +1,6 @@ """Constants for the Yamaha (YNCA) integration.""" import logging -import ynca DOMAIN = "yamaha_ynca" LOGGER = logging.getLogger(__package__) @@ -14,13 +13,14 @@ MANUFACTURER_NAME = "Yamaha" +ZONE_MAX_VOLUME = 16.5 # Seems to be 16.5 when MAXVOL function not implemented ZONE_MIN_VOLUME = -80.5 -ZONE_SUBUNIT_IDS = [ - ynca.Subunit.MAIN, - ynca.Subunit.ZONE2, - ynca.Subunit.ZONE3, - ynca.Subunit.ZONE4, +ZONE_SUBUNITS = [ + "main", + "zone2", + "zone3", + "zone4", ] CONF_HIDDEN_SOUND_MODES = "hidden_sound_modes" diff --git a/custom_components/yamaha_ynca/diagnostics.py b/custom_components/yamaha_ynca/diagnostics.py index 33a11df..d28d43b 100644 --- a/custom_components/yamaha_ynca/diagnostics.py +++ b/custom_components/yamaha_ynca/diagnostics.py @@ -23,11 +23,11 @@ async def async_get_config_entry_diagnostics( # Add data from the device itself domain_entry_data: DomainEntryData = hass.data[DOMAIN].get(entry.entry_id, None) if domain_entry_data: - api: ynca.Ynca = domain_entry_data.api - if api.SYS: - data["SYS"] = { - "modelname": api.SYS.modelname, - "version": api.SYS.version, + api: ynca.YncaApi = domain_entry_data.api + if api.sys: + data["sys"] = { + "modelname": api.sys.modelname, + "version": api.sys.version, } data["communication"] = { "initialization": domain_entry_data.initialization_events, diff --git a/custom_components/yamaha_ynca/helpers.py b/custom_components/yamaha_ynca/helpers.py index 6e54b9a..d6a37dc 100644 --- a/custom_components/yamaha_ynca/helpers.py +++ b/custom_components/yamaha_ynca/helpers.py @@ -8,7 +8,7 @@ @dataclass class DomainEntryData: - api: ynca.Ynca + api: ynca.YncaApi initialization_events: List[str] diff --git a/custom_components/yamaha_ynca/input_helpers.py b/custom_components/yamaha_ynca/input_helpers.py new file mode 100644 index 0000000..f0e18a7 --- /dev/null +++ b/custom_components/yamaha_ynca/input_helpers.py @@ -0,0 +1,131 @@ +"""Helpers for the Yamaha (YNCA) integration.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List + +import ynca + + +@dataclass +class Mapping: + ynca_input: ynca.Input + subunit_attribute_name: str | None + + +input_mappings: List[Mapping] = [ + # Inputs provided by subunits + Mapping(ynca.Input.AIRPLAY, "airplay"), + Mapping(ynca.Input.BLUETOOTH, "bt"), + Mapping(ynca.Input.IPOD, "ipod"), + Mapping(ynca.Input.IPOD_USB, "ipodusb"), + Mapping(ynca.Input.NAPSTER, "napster"), + Mapping(ynca.Input.NETRADIO, "netradio"), + Mapping(ynca.Input.PANDORA, "pandora"), + Mapping(ynca.Input.PC, "pc"), + Mapping(ynca.Input.RHAPSODY, "rhap"), + Mapping(ynca.Input.SERVER, "server"), + Mapping(ynca.Input.SIRIUS, "sirius"), + Mapping(ynca.Input.SIRIUS_IR, "siriusir"), + Mapping(ynca.Input.SIRIUS_XM, "siriusxm"), + Mapping(ynca.Input.SPOTIFY, "spotify"), + Mapping(ynca.Input.TUNER, "tun"), + Mapping(ynca.Input.UAW, "uaw"), + Mapping(ynca.Input.USB, "usb"), + # Inputs with connectors on the receiver + Mapping(ynca.Input.AUDIO1, None), + Mapping(ynca.Input.AUDIO2, None), + Mapping(ynca.Input.AUDIO3, None), + Mapping(ynca.Input.AUDIO4, None), + Mapping(ynca.Input.AV1, None), + Mapping(ynca.Input.AV2, None), + Mapping(ynca.Input.AV3, None), + Mapping(ynca.Input.AV4, None), + Mapping(ynca.Input.AV5, None), + Mapping(ynca.Input.AV6, None), + Mapping(ynca.Input.AV7, None), + Mapping(ynca.Input.DOCK, None), + Mapping(ynca.Input.HDMI1, None), + Mapping(ynca.Input.HDMI2, None), + Mapping(ynca.Input.HDMI3, None), + Mapping(ynca.Input.HDMI4, None), + Mapping(ynca.Input.HDMI5, None), + Mapping(ynca.Input.HDMI6, None), + Mapping(ynca.Input.HDMI7, None), + Mapping(ynca.Input.MULTICH, None), + Mapping(ynca.Input.PHONO, None), + Mapping(ynca.Input.VAUX, None), + Mapping(ynca.Input.USB, None), +] + + +class InputHelper: + @staticmethod + def get_subunit_for_input(api: ynca.YncaApi, input: ynca.Input): + """Returns Subunit of the current provided input if possible, otherwise None""" + for mapping in input_mappings: + if ( + mapping.ynca_input is input + and mapping.subunit_attribute_name is not None + ): + return getattr(api, mapping.subunit_attribute_name, None) + return None + + @staticmethod + def get_input_by_name(api: ynca.YncaApi, name: str) -> ynca.Input | None: + """Returns input by name""" + source_mapping = InputHelper.get_source_mapping(api) + for source_input, source_name in source_mapping.items(): + if source_name == name: + return source_input + return None + + @staticmethod + def get_name_of_input(api: ynca.YncaApi, input: ynca.Input) -> str | None: + source_mapping = InputHelper.get_source_mapping(api) + for source_input, source_name in source_mapping.items(): + if input is source_input: + return source_name + return None + + @staticmethod + def get_source_mapping(api: ynca.YncaApi) -> Dict[ynca.Input, str]: + """Mapping of input to sourcename for this YNCA instance.""" + source_mapping = {} + + # keep track of added inputs as some are renamable and subunits (e.g. USB) + inputs_added = set() + + # Try renameable inputs first + # this will also weed out inputs that are not supported on the specific receiver + for mapping in input_mappings: + # Use the input name with only letters and numbers + # Solves cases like V-AUX input vs VAUX inputnmamevaux + postfix = "".join( + x + for x in mapping.ynca_input.value.lower() + if x.isalpha() or x.isdigit() + ) + + if name := getattr(api.sys, f"inpname{postfix}", None): + inputs_added.add(mapping.ynca_input) + source_mapping[mapping.ynca_input] = name + continue + + # Some receivers don't expose external inputs as renameable so just add them all + if len(source_mapping) == 0: + for mapping in input_mappings: + if mapping.subunit_attribute_name is None: + inputs_added.add(mapping.ynca_input) + source_mapping[mapping.ynca_input] = mapping.ynca_input.value + + # Add sources from subunits + for mapping in input_mappings: + if ( + mapping.subunit_attribute_name + and mapping.ynca_input not in inputs_added + ): + if getattr(api, mapping.subunit_attribute_name): + source_mapping[mapping.ynca_input] = mapping.ynca_input.value + + return source_mapping diff --git a/custom_components/yamaha_ynca/manifest.json b/custom_components/yamaha_ynca/manifest.json index 3fd4b0a..f4cef27 100644 --- a/custom_components/yamaha_ynca/manifest.json +++ b/custom_components/yamaha_ynca/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://github.com/mvdwetering/yamaha_ynca", "issue_tracker": "https://github.com/mvdwetering/yamaha_ynca/issues", "requirements": [ - "ynca==3.11.0" + "ynca==4.0.0b2" ], "ssdp": [], "zeroconf": [], diff --git a/custom_components/yamaha_ynca/media_player.py b/custom_components/yamaha_ynca/media_player.py index 74604c1..b028735 100644 --- a/custom_components/yamaha_ynca/media_player.py +++ b/custom_components/yamaha_ynca/media_player.py @@ -14,16 +14,17 @@ from homeassistant.config_entries import ConfigEntry - from .const import ( CONF_HIDDEN_SOUND_MODES, DOMAIN, LOGGER, + ZONE_MAX_VOLUME, ZONE_MIN_VOLUME, - ZONE_SUBUNIT_IDS, + ZONE_SUBUNITS, CONF_HIDDEN_INPUTS_FOR_ZONE, ) from .helpers import scale, DomainEntryData +from .input_helpers import InputHelper SUPPORT_YAMAHA_YNCA_BASE = ( MediaPlayerEntityFeature.VOLUME_SET @@ -34,20 +35,6 @@ | MediaPlayerEntityFeature.SELECT_SOURCE ) -LIMITED_PLAYBACK_CONTROL_SUBUNITS = [ - ynca.Subunit.NETRADIO, - ynca.Subunit.SIRIUS, - ynca.Subunit.SIRIUSIR, - ynca.Subunit.SIRIUSXM, -] - -RADIO_SOURCES = [ - ynca.Subunit.NETRADIO, - ynca.Subunit.TUN, - ynca.Subunit.SIRIUS, - ynca.Subunit.SIRIUSIR, - ynca.Subunit.SIRIUSXM, -] STRAIGHT = "Straight" @@ -57,10 +44,10 @@ async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities) domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] entities = [] - for zone_subunit_id in ZONE_SUBUNIT_IDS: - if zone_subunit := getattr(domain_entry_data.api, zone_subunit_id): + for zone_attr_name in ZONE_SUBUNITS: + if zone_subunit := getattr(domain_entry_data.api, zone_attr_name): hidden_inputs = config_entry.options.get( - CONF_HIDDEN_INPUTS_FOR_ZONE(zone_subunit_id), [] + CONF_HIDDEN_INPUTS_FOR_ZONE(zone_attr_name.upper()), [] ) hidden_sound_modes = config_entry.options.get(CONF_HIDDEN_SOUND_MODES, []) @@ -87,8 +74,8 @@ class YamahaYncaZone(MediaPlayerEntity): def __init__( self, receiver_unique_id: str, - ynca: ynca.Ynca, - zone: Type[ynca.zone.ZoneBase], + ynca: ynca.YncaApi, + zone: Type[ynca.subunits.zone.ZoneBase], hidden_inputs: List[str], hidden_sound_modes: List[str], ): @@ -102,47 +89,34 @@ def __init__( "identifiers": {(DOMAIN, receiver_unique_id)}, } - def update_callback(self): + def update_callback(self, function, value): self.schedule_update_ha_state() def _get_input_subunits(self): - for inputinfo in ynca.get_inputinfo_list(self._ynca): - if inputinfo.subunit is None: + for attribute in sorted(dir(self._ynca)): + if attribute in ["sys", "main", "zone2", "zone3", "zone4"]: continue - if subunit := getattr(self._ynca, inputinfo.subunit.value, None): - yield subunit + if attribute_instance := getattr(self._ynca, attribute): + if isinstance(attribute_instance, ynca.subunit.SubunitBase): + yield attribute_instance async def async_added_to_hass(self): # Register to catch input renames on SYS - self._ynca.SYS.register_update_callback(self.update_callback) + self._ynca.sys.register_update_callback(self.update_callback) self._zone.register_update_callback(self.update_callback) - # TODO: Optimize registrations as now all zones get triggered by all changes - # even when change happens on subunit that is not input of this zone for subunit in self._get_input_subunits(): subunit.register_update_callback(self.update_callback) async def async_will_remove_from_hass(self): - self._ynca.SYS.unregister_update_callback(self.update_callback) + self._ynca.sys.unregister_update_callback(self.update_callback) self._zone.unregister_update_callback(self.update_callback) for subunit in self._get_input_subunits(): subunit.unregister_update_callback(self.update_callback) - def _get_input_from_source(self, source): - for inputinfo in ynca.get_inputinfo_list(self._ynca): - if inputinfo.name == source: - return inputinfo.input - return None - - def _input_subunit(self): - """Returns Subunit for current selected input if possible, otherwise None""" - for inputinfo in ynca.get_inputinfo_list(self._ynca): - if inputinfo.subunit is None: - continue - if inputinfo.input == self._zone.inp: - return getattr(self._ynca, inputinfo.subunit.value, None) - return None + def _get_input_subunit(self): + return InputHelper.get_subunit_for_input(self._ynca, self._zone.inp) @property def name(self): @@ -152,10 +126,10 @@ def name(self): @property def state(self): """Return the state of the entity.""" - if not self._zone.pwr: + if self._zone.pwr == ynca.Pwr.STANDBY: return MediaPlayerState.OFF - if input_subunit := self._input_subunit(): + if input_subunit := self._get_input_subunit(): playbackinfo = getattr(input_subunit, "playbackinfo", None) if playbackinfo == ynca.PlaybackInfo.PLAY: return MediaPlayerState.PLAYING @@ -169,46 +143,41 @@ def state(self): @property def volume_level(self): """Volume level of the media player (0..1).""" - return scale(self._zone.vol, [ZONE_MIN_VOLUME, self._zone.maxvol], [0, 1]) + return scale( + self._zone.vol, + [ZONE_MIN_VOLUME, self._zone.maxvol or ZONE_MAX_VOLUME], + [0, 1], + ) @property def is_volume_muted(self): """Boolean if volume is currently muted.""" - return self._zone.mute != ynca.Mute.off + return self._zone.mute != ynca.Mute.OFF @property def source(self): """Return the current input source.""" - current_input_info = [ - inputinfo - for inputinfo in ynca.get_inputinfo_list(self._ynca) - if inputinfo.input == self._zone.inp - ] - return ( - current_input_info[0].name - if len(current_input_info) > 0 - else self._zone.inp - ) + return InputHelper.get_name_of_input(self._ynca, self._zone.inp) or "Unknown" @property - def source_list(self): - """List of available input sources.""" - inputinfos = ynca.get_inputinfo_list(self._ynca) - filtered_inputs = [ - inputinfo.name - for inputinfo in inputinfos - if inputinfo.input not in self._hidden_inputs + def source_list(self) -> List[str]: + """List of available sources.""" + source_mapping = InputHelper.get_source_mapping(self._ynca) + + filtered_sources = [ + name + for input, name in source_mapping.items() + if input.value not in self._hidden_inputs ] - # Return the user given names instead HDMI1 etc... - return sorted( - filtered_inputs, key=str.lower - ) # Using `str.lower` does not work for all languages, but better than nothing + return sorted(filtered_sources, key=str.lower) @property def sound_mode(self): - """Return the current input source.""" - return STRAIGHT if self._zone.straight else self._zone.soundprg + """Return the current input sound mode.""" + return ( + STRAIGHT if self._zone.straight is ynca.Straight.ON else self._zone.soundprg + ) @property def sound_mode_list(self): @@ -217,10 +186,11 @@ def sound_mode_list(self): if self._zone.straight is not None: sound_modes.append(STRAIGHT) if self._zone.soundprg: - modelinfo = ynca.get_modelinfo(self._ynca.SYS.modelname) + modelinfo = ynca.YncaModelInfo.get(self._ynca.sys.modelname) device_sound_modes = [ sound_mode.value for sound_mode in (modelinfo.soundprg if modelinfo else ynca.SoundPrg) + if sound_mode is not ynca.SoundPrg.UNKNOWN ] sound_modes.extend(device_sound_modes) @@ -234,6 +204,15 @@ def sound_mode_list(self): return sound_modes if len(sound_modes) > 0 else None + def _has_limited_playback_controls(self, subunit): + """Indicates if subunit has limited playback control (aka only Play and Stop)""" + return ( + subunit is self._ynca.netradio + or subunit is self._ynca.sirius + or subunit is self._ynca.siriusir + or subunit is self._ynca.siriusxm + ) + @property def supported_features(self): """Flag of media commands that are supported.""" @@ -241,11 +220,11 @@ def supported_features(self): if self._zone.soundprg: supported_commands |= MediaPlayerEntityFeature.SELECT_SOUND_MODE - if input_subunit := self._input_subunit(): + if input_subunit := self._get_input_subunit(): if hasattr(input_subunit, "playback"): supported_commands |= MediaPlayerEntityFeature.PLAY supported_commands |= MediaPlayerEntityFeature.STOP - if input_subunit.id not in LIMITED_PLAYBACK_CONTROL_SUBUNITS: + if not self._has_limited_playback_controls(input_subunit): supported_commands |= MediaPlayerEntityFeature.PAUSE supported_commands |= MediaPlayerEntityFeature.NEXT_TRACK supported_commands |= MediaPlayerEntityFeature.PREVIOUS_TRACK @@ -257,15 +236,17 @@ def supported_features(self): def turn_on(self): """Turn the media player on.""" - self._zone.pwr = True + self._zone.pwr = ynca.Pwr.ON def turn_off(self): """Turn off media player.""" - self._zone.pwr = False + self._zone.pwr = ynca.Pwr.STANDBY def set_volume_level(self, volume): """Set volume level, convert range from 0..1.""" - self._zone.vol = scale(volume, [0, 1], [ZONE_MIN_VOLUME, self._zone.maxvol]) + self._zone.vol = scale( + volume, [0, 1], [ZONE_MIN_VOLUME, self._zone.maxvol or ZONE_MAX_VOLUME] + ) def volume_up(self): """Volume up media player.""" @@ -277,20 +258,20 @@ def volume_down(self): def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" - self._zone.mute = ynca.Mute.on if mute else ynca.Mute.off + self._zone.mute = ynca.Mute.ON if mute else ynca.Mute.OFF def select_source(self, source): """Select input source.""" - if input := self._get_input_from_source(source): + if input := InputHelper.get_input_by_name(self._ynca, source): self._zone.inp = input def select_sound_mode(self, sound_mode): """Switch the sound mode of the entity.""" if sound_mode == STRAIGHT: - self._zone.straight = True + self._zone.straight = ynca.Straight.ON else: - self._zone.straight = False - self._zone.soundprg = sound_mode + self._zone.straight = ynca.Straight.OFF + self._zone.soundprg = ynca.SoundPrg(sound_mode) # Playback controls (zone forwards to active subunit automatically it seems) def media_play(self): @@ -311,18 +292,21 @@ def media_previous_track(self): @property def shuffle(self) -> Optional[bool]: """Boolean if shuffle is enabled.""" - if subunit := self._input_subunit(): - return getattr(subunit, "shuffle", None) + if subunit := self._get_input_subunit(): + if shuffle := getattr(subunit, "shuffle", None): + return shuffle == ynca.Shuffle.ON return None def set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" - self._input_subunit().shuffle = shuffle + self._get_input_subunit().shuffle = ( + ynca.Shuffle.ON if shuffle else ynca.Shuffle.OFF + ) @property def repeat(self) -> Optional[str]: """Return current repeat mode.""" - if subunit := self._input_subunit(): + if subunit := self._get_input_subunit(): if repeat := getattr(subunit, "repeat", None): if repeat == ynca.Repeat.SINGLE: return RepeatMode.ONE @@ -334,7 +318,7 @@ def repeat(self) -> Optional[str]: def set_repeat(self, repeat): """Set repeat mode.""" - subunit = self._input_subunit() + subunit = self._get_input_subunit() if repeat == RepeatMode.ALL: subunit.repeat = ynca.Repeat.ALL elif repeat == RepeatMode.OFF: @@ -342,12 +326,21 @@ def set_repeat(self, repeat): elif repeat == RepeatMode.ONE: subunit.repeat = ynca.Repeat.SINGLE + def _is_radio_subunit(self, subunit: ynca.subunit.Subunit) -> bool: + return ( + subunit is self._ynca.netradio + or subunit is self._ynca.tun + or subunit is self._ynca.sirius + or subunit is self._ynca.siriusir + or subunit is self._ynca.siriusxm + ) + # Media info @property def media_content_type(self) -> Optional[str]: """Content type of current playing media.""" - if subunit := self._input_subunit(): - if subunit.id in RADIO_SOURCES: + if subunit := self._get_input_subunit(): + if self._is_radio_subunit(subunit): return MediaType.CHANNEL if getattr(subunit, "song", None) is not None: return MediaType.MUSIC @@ -356,36 +349,40 @@ def media_content_type(self) -> Optional[str]: @property def media_title(self) -> Optional[str]: """Title of current playing media.""" - if subunit := self._input_subunit(): + if subunit := self._get_input_subunit(): return getattr(subunit, "song", None) return None @property def media_artist(self) -> Optional[str]: """Artist of current playing media, music track only.""" - if subunit := self._input_subunit(): + if subunit := self._get_input_subunit(): return getattr(subunit, "artist", None) return None @property def media_album_name(self) -> Optional[str]: """Album name of current playing media, music track only.""" - if subunit := self._input_subunit(): + if subunit := self._get_input_subunit(): return getattr(subunit, "album", None) return None @property def media_channel(self) -> Optional[str]: """Channel currently playing.""" - if subunit := self._input_subunit(): - if subunit.id == ynca.Subunit.TUN: - return ( - f"FM {subunit.fmfreq:.2f} MHz" - if subunit.band == ynca.Band.FM - else f"AM {subunit.amfreq} kHz" - ) - if subunit.id == ynca.Subunit.NETRADIO: - return subunit.station - channelname = getattr(subunit, "chname", None) # Sirius* + if subunit := self._get_input_subunit(): + # Tuner + if band := getattr(subunit, "band", None): + if band is ynca.BandTun.FM: + return f"FM {subunit.fmfreq:.2f} MHz" + if band is ynca.BandTun.AM: + return f"AM {subunit.amfreq} kHz" + + # Netradio + if station := getattr(subunit, "station", None): + return station + + # Sirius variants + channelname = getattr(subunit, "chname", None) return channelname return None diff --git a/requirements.txt b/requirements.txt index 2dfb786..ca37e51 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -ynca==3.11.0 \ No newline at end of file +ynca==4.0.0b2 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index add6c83..d6d3b92 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,11 +12,32 @@ mock_device_registry, ) - import custom_components.yamaha_ynca as yamaha_ynca + import ynca +INPUT_SUBUNITS = [ + "airplay", + "bt", + "ipod", + "ipodusb", + "napster", + "netradio", + "pandora", + "pc", + "rhap", + "server", + "sirius", + "siriusir", + "siriusxm", + "spotify", + "tun", + "uaw", + "usb", +] + + @pytest.fixture(autouse=True) def auto_enable_custom_integrations(enable_custom_integrations): yield @@ -24,11 +45,29 @@ def auto_enable_custom_integrations(enable_custom_integrations): @pytest.fixture def mock_ynca(hass): - """Create a mocked YNCA instance.""" + """Create a mocked YNCA instance without any inputs or subunits.""" mock_ynca = Mock( - spec=ynca.Ynca, + spec=ynca.YncaApi, ) + # No zones by default + mock_ynca.main = None + mock_ynca.zone2 = None + mock_ynca.zone3 = None + mock_ynca.zone4 = None + + # No input subunits + for input_subunit in INPUT_SUBUNITS: + setattr(mock_ynca, input_subunit, None) + + mock_sys = Mock(spec=ynca.subunits.system.System) + mock_ynca.sys = mock_sys + + # Clear external input names + for attribute in dir(mock_sys): + if attribute.startswith("inpname"): + setattr(mock_sys, attribute, None) + return mock_ynca @@ -52,7 +91,7 @@ async def setup_integration( modelname="ModelName", ): entry = MockConfigEntry( - version=3, + version=5, domain=yamaha_ynca.DOMAIN, entry_id="entry_id", title="ModelName", @@ -68,12 +107,12 @@ def side_effect(*args, **kwargs): on_disconnect = args[1] return DEFAULT - mock_ynca = mock_ynca or create_autospec(ynca.Ynca) + mock_ynca = mock_ynca or create_autospec(ynca.YncaApi) - mock_ynca.SYS.modelname = modelname - mock_ynca.SYS.version = "Version" + mock_ynca.sys.modelname = modelname + mock_ynca.sys.version = "Version" - with patch("ynca.Ynca", return_value=mock_ynca, side_effect=side_effect): + with patch("ynca.YncaApi", return_value=mock_ynca, side_effect=side_effect): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/test_button.py b/tests/test_button.py index 06994b3..1950c0e 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -1,42 +1,93 @@ -from unittest.mock import Mock +from unittest.mock import Mock, call, patch import custom_components.yamaha_ynca as yamaha_ynca import pytest import ynca -from custom_components.yamaha_ynca.button import YamahaYncaSceneButton +from custom_components.yamaha_ynca.button import ( + YamahaYncaSceneButton, + async_setup_entry, +) +from tests.conftest import setup_integration @pytest.fixture def mock_zone(): """Create a mocked Zone instance.""" zone = Mock( - spec=ynca.zone.ZoneBase, + spec=ynca.subunits.zone.ZoneBase, ) zone.id = "ZoneId" zone.zonename = "ZoneName" - zone.scenenames = {"1234": "SceneName 1234"} + zone.scene1name = "SceneName One" return zone +@patch("custom_components.yamaha_ynca.button.YamahaYncaSceneButton", autospec=True) +async def test_async_setup_entry( + yamahayncascenebutton_mock, + hass, + mock_ynca, +): + + mock_ynca.main = Mock(spec=ynca.subunits.zone.Main) + mock_ynca.zone2 = Mock(spec=ynca.subunits.zone.Zone2) + + for scene_id in range(1, 12 + 1): + setattr(mock_ynca.main, f"scene{scene_id}name", None) + setattr(mock_ynca.zone2, f"scene{scene_id}name", None) + + mock_ynca.main.zonename = "_MAIN_" + mock_ynca.main.scene1name = "SCENE_1" + mock_ynca.main.scene4name = "SCENE_4" + mock_ynca.zone2.zonename = "_ZONE2_" + mock_ynca.zone2.scene12name = "SCENE_12" + + integration = await setup_integration(hass, mock_ynca, modelname="RX-A810") + add_entities_mock = Mock() + + await async_setup_entry(hass, integration.entry, add_entities_mock) + + yamahayncascenebutton_mock.assert_has_calls( + [ + call("entry_id", mock_ynca.main, 1), + call("entry_id", mock_ynca.main, 4), + call("entry_id", mock_ynca.zone2, 12), + ] + ) + + add_entities_mock.assert_called_once() + entities = add_entities_mock.call_args.args[0] + assert len(entities) == 3 + + async def test_button_entity(mock_zone): - entity = YamahaYncaSceneButton("ReceiverUniqueId", mock_zone, "1234") + entity = YamahaYncaSceneButton("ReceiverUniqueId", mock_zone, "1") - assert entity.unique_id == "ReceiverUniqueId_ZoneId_scene_1234" + assert entity.unique_id == "ReceiverUniqueId_ZoneId_scene_1" assert entity.device_info["identifiers"] == { (yamaha_ynca.DOMAIN, "ReceiverUniqueId") } - assert entity.name == "ZoneName: SceneName 1234" + assert entity.name == "ZoneName: SceneName One" entity.press() - mock_zone.activate_scene.assert_called_once_with("1234") + mock_zone.scene.assert_called_once_with("1") await entity.async_added_to_hass() mock_zone.register_update_callback.assert_called_once() + callback = mock_zone.register_update_callback.call_args.args[0] + entity.schedule_update_ha_state = Mock() + + callback("SCENE11NAME", None) + entity.schedule_update_ha_state.assert_not_called() + + callback("ZONENAME", None) + assert entity.schedule_update_ha_state.call_count == 1 + callback("SCENE1NAME", None) + assert entity.schedule_update_ha_state.call_count == 2 + await entity.async_will_remove_from_hass() - mock_zone.unregister_update_callback.assert_called_once_with( - mock_zone.register_update_callback.call_args.args[0] - ) + mock_zone.unregister_update_callback.assert_called_once_with(callback) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 8e7cd3c..db15e5a 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Yamaha (YNCA) config flow.""" -from unittest.mock import patch +from unittest.mock import Mock, create_autospec, patch from homeassistant import config_entries from homeassistant.core import HomeAssistant @@ -29,7 +29,7 @@ async def test_network_connect(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "ynca.Ynca.connection_check", + "ynca.YncaApi.connection_check", return_value="ModelName", ) as mock_setup, patch( "custom_components.yamaha_ynca.async_setup_entry", @@ -62,7 +62,7 @@ async def test_advanced_connect(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "ynca.Ynca.connection_check", + "ynca.YncaApi.connection_check", return_value="ModelName", ) as mock_setup, patch( "custom_components.yamaha_ynca.async_setup_entry", @@ -92,7 +92,7 @@ async def test_connection_error(hass: HomeAssistant) -> None: ) with patch( - "ynca.Ynca.connection_check", + "ynca.YncaApi.connection_check", side_effect=ynca.YncaConnectionError("Connection error"), ): result2 = await hass.config_entries.flow.async_configure( @@ -113,7 +113,7 @@ async def test_connection_failed(hass: HomeAssistant) -> None: ) with patch( - "ynca.Ynca.connection_check", + "ynca.YncaApi.connection_check", side_effect=ynca.YncaConnectionFailed("Connection failed"), ): result2 = await hass.config_entries.flow.async_configure( @@ -134,7 +134,7 @@ async def test_unhandled_exception(hass: HomeAssistant) -> None: ) with patch( - "ynca.Ynca.connection_check", + "ynca.YncaApi.connection_check", side_effect=Exception("Unhandled exception"), ): result2 = await hass.config_entries.flow.async_configure( @@ -150,43 +150,40 @@ async def test_unhandled_exception(hass: HomeAssistant) -> None: async def test_options_flow(hass: HomeAssistant, mock_ynca) -> None: """Test optionsflow.""" - with patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(None, "INPUT_ID_1", "Input Name 1"), - ynca.InputInfo(None, "INPUT_ID_2", "Input Name 2"), - ], - ): - integration = await setup_integration(hass, mock_ynca, modelname="RX-A810") - options = dict(integration.entry.options) - options[yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES] = [ - "UNSUPPORTED", # Test that obsolete values don't break the schema - ] - integration.entry.options = options - - result = await hass.config_entries.options.async_init( - integration.entry.entry_id - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" + mock_ynca.main = Mock(spec=ynca.subunits.zone.Main) + mock_ynca.zone2 = Mock(spec=ynca.subunits.zone.Zone2) + mock_ynca.zone3 = Mock(spec=ynca.subunits.zone.Zone3) + mock_ynca.sys.inpnamehdmi4 = "_INPNAMEHDMI4_" + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("MAIN"): ["INPUT_ID_1"], - yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE2"): ["INPUT_ID_2"], - yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES: [ - "Hall in Vienna", - ], - }, - ) + integration = await setup_integration(hass, mock_ynca, modelname="RX-A810") + options = dict(integration.entry.options) + options[yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES] = [ + "Obsolete", # Test that obsolete values don't break the schema + ] + integration.entry.options = options - assert result["type"] == "create_entry" - assert result["data"] == { - yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("MAIN"): ["INPUT_ID_1"], - yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE2"): ["INPUT_ID_2"], - yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE3"): [], - yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE4"): [], - yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES: ["Hall in Vienna"], - } + result = await hass.config_entries.options.async_init(integration.entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("MAIN"): ["HDMI4"], + yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE2"): ["NET RADIO"], + yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES: [ + "Hall in Vienna", + ], + }, + ) + + assert result["type"] == "create_entry" + assert result["data"] == { + yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("MAIN"): ["HDMI4"], + yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE2"): ["NET RADIO"], + yamaha_ynca.const.CONF_HIDDEN_INPUTS_FOR_ZONE("ZONE3"): [], + yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES: ["Hall in Vienna"], + } diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 9c31e75..b36889d 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -13,9 +13,9 @@ async def test_diagnostics(hass: HomeAssistant): assert "config_entry" in diagnostics - assert "SYS" in diagnostics - assert diagnostics["SYS"]["modelname"] == "ModelName" - assert diagnostics["SYS"]["version"] == "Version" + assert "sys" in diagnostics + assert diagnostics["sys"]["modelname"] == "ModelName" + assert diagnostics["sys"]["version"] == "Version" assert "communication" in diagnostics assert "initialization" in diagnostics["communication"] diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 05b5c36..ba36a16 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -3,15 +3,3 @@ def test_scale(hass): assert scale(1, [1, 10], [2, 11]) == 2 - - -# def test_serial_url_from_user_input_ip_address_ok(hass): -# assert serial_url_from_user_input("1.2.3.4") == "socket://1.2.3.4:50000" -# assert serial_url_from_user_input("1.2.3.4:5") == "socket://1.2.3.4:5" - - -# def test_serial_url_from_user_input_not_an_ip_address(hass): -# assert serial_url_from_user_input("not an ip address") == "not an ip address" -# assert serial_url_from_user_input("1.2.3.999") == "1.2.3.999" -# assert serial_url_from_user_input("1.2.3.4:abcd") == "1.2.3.4:abcd" -# assert serial_url_from_user_input("1.2.3.4:5:6") == "1.2.3.4:5:6" diff --git a/tests/test_init.py b/tests/test_init.py index a4fb897..3cd76bc 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -29,8 +29,6 @@ async def test_async_setup_entry(hass, device_reg): assert device.name == "Yamaha ModelName" assert device.configuration_url is None - # TODO Check for entities/states - async def test_async_setup_entry_socket_has_configuration_url(hass, device_reg): """Test a successful setup entry.""" @@ -46,10 +44,10 @@ async def test_async_setup_entry_fails_with_connection_error(hass): """Test a successful setup entry.""" integration = await setup_integration(hass, skip_setup=True) - mock_ynca = create_autospec(ynca.Ynca) + mock_ynca = create_autospec(ynca.YncaApi) mock_ynca.initialize.side_effect = ynca.YncaConnectionError("Connection error") - with patch("ynca.Ynca", return_value=mock_ynca): + with patch("ynca.YncaApi", return_value=mock_ynca): await hass.config_entries.async_setup(integration.entry.entry_id) await hass.async_block_till_done() @@ -61,12 +59,12 @@ async def test_async_setup_entry_fails_with_initialization_failed_error(hass): """Test a successful setup entry.""" integration = await setup_integration(hass, skip_setup=True) - mock_ynca = create_autospec(ynca.Ynca) + mock_ynca = create_autospec(ynca.YncaApi) mock_ynca.initialize.side_effect = ynca.YncaInitializationFailedException( "Initialize failed" ) - with patch("ynca.Ynca", return_value=mock_ynca): + with patch("ynca.YncaApi", return_value=mock_ynca): await hass.config_entries.async_setup(integration.entry.entry_id) await hass.async_block_till_done() @@ -78,10 +76,10 @@ async def test_async_setup_entry_fails_unknown_reason(hass): """Test a successful setup entry.""" integration = await setup_integration(hass, skip_setup=True) - mock_ynca = create_autospec(ynca.Ynca) + mock_ynca = create_autospec(ynca.YncaApi) mock_ynca.initialize.side_effect = Exception("Unexpected exception") - with patch("ynca.Ynca", return_value=mock_ynca): + with patch("ynca.YncaApi", return_value=mock_ynca): await hass.config_entries.async_setup(integration.entry.entry_id) await hass.async_block_till_done() diff --git a/tests/test_input_helpers.py b/tests/test_input_helpers.py new file mode 100644 index 0000000..de7fa87 --- /dev/null +++ b/tests/test_input_helpers.py @@ -0,0 +1,194 @@ +from custom_components.yamaha_ynca.input_helpers import InputHelper + +import ynca + +from tests.conftest import INPUT_SUBUNITS + + +def test_sourcelist_inpnames_set(mock_ynca): + + # Setup external input names + mock_sys = mock_ynca.sys + for attribute in dir(mock_sys): + if attribute.startswith("inpname"): + setattr(mock_sys, attribute, f"_{attribute.upper()}_") + + mapping = InputHelper.get_source_mapping(mock_ynca) + + assert mapping[ynca.Input.AUDIO1] == "_INPNAMEAUDIO1_" + assert mapping[ynca.Input.AUDIO2] == "_INPNAMEAUDIO2_" + assert mapping[ynca.Input.AUDIO3] == "_INPNAMEAUDIO3_" + assert mapping[ynca.Input.AUDIO4] == "_INPNAMEAUDIO4_" + assert mapping[ynca.Input.AV1] == "_INPNAMEAV1_" + assert mapping[ynca.Input.AV2] == "_INPNAMEAV2_" + assert mapping[ynca.Input.AV3] == "_INPNAMEAV3_" + assert mapping[ynca.Input.AV4] == "_INPNAMEAV4_" + assert mapping[ynca.Input.AV5] == "_INPNAMEAV5_" + assert mapping[ynca.Input.AV6] == "_INPNAMEAV6_" + assert mapping[ynca.Input.AV7] == "_INPNAMEAV7_" + assert mapping[ynca.Input.DOCK] == "_INPNAMEDOCK_" + assert mapping[ynca.Input.HDMI1] == "_INPNAMEHDMI1_" + assert mapping[ynca.Input.HDMI2] == "_INPNAMEHDMI2_" + assert mapping[ynca.Input.HDMI3] == "_INPNAMEHDMI3_" + assert mapping[ynca.Input.HDMI4] == "_INPNAMEHDMI4_" + assert mapping[ynca.Input.HDMI5] == "_INPNAMEHDMI5_" + assert mapping[ynca.Input.HDMI6] == "_INPNAMEHDMI6_" + assert mapping[ynca.Input.HDMI7] == "_INPNAMEHDMI7_" + assert mapping[ynca.Input.MULTICH] == "_INPNAMEMULTICH_" + assert mapping[ynca.Input.PHONO] == "_INPNAMEPHONO_" + assert mapping[ynca.Input.VAUX] == "_INPNAMEVAUX_" + assert mapping[ynca.Input.USB] == "_INPNAMEUSB_" + + +def test_sourcelist_inpname_some_set(mock_ynca): + """ + Scenario when a receiver supports some of the inputs and therefore + responds with only a subset of INPNAMEs + """ + # Setup 1 input name + mock_ynca.sys.inpnamehdmi4 = "_INPNAMEHDMI4_" + + mapping = InputHelper.get_source_mapping(mock_ynca) + + for input in ynca.Input: + if input is ynca.Input.HDMI4: + assert mapping[input] == "_INPNAMEHDMI4_" + else: + assert input not in mapping.keys() + + +def test_sourcelist_inpnames_not_set(mock_ynca): + """ + Some receivers do not report INPNAMES at all + Check that they all known are reported with default names + """ + + mapping = InputHelper.get_source_mapping(mock_ynca) + + assert mapping[ynca.Input.AUDIO2] == "AUDIO2" + assert mapping[ynca.Input.AUDIO3] == "AUDIO3" + assert mapping[ynca.Input.AUDIO4] == "AUDIO4" + assert mapping[ynca.Input.AV1] == "AV1" + assert mapping[ynca.Input.AV2] == "AV2" + assert mapping[ynca.Input.AV3] == "AV3" + assert mapping[ynca.Input.AV4] == "AV4" + assert mapping[ynca.Input.AV5] == "AV5" + assert mapping[ynca.Input.AV6] == "AV6" + assert mapping[ynca.Input.AV7] == "AV7" + assert mapping[ynca.Input.DOCK] == "DOCK" + assert mapping[ynca.Input.HDMI1] == "HDMI1" + assert mapping[ynca.Input.HDMI2] == "HDMI2" + assert mapping[ynca.Input.HDMI3] == "HDMI3" + assert mapping[ynca.Input.HDMI4] == "HDMI4" + assert mapping[ynca.Input.HDMI5] == "HDMI5" + assert mapping[ynca.Input.HDMI6] == "HDMI6" + assert mapping[ynca.Input.HDMI7] == "HDMI7" + assert mapping[ynca.Input.MULTICH] == "MULTI CH" + assert mapping[ynca.Input.PHONO] == "PHONO" + assert mapping[ynca.Input.VAUX] == "V-AUX" + assert mapping[ynca.Input.USB] == "USB" + + +def test_sourcelist_input_subunits(mock_ynca): + """ + Check names of input subunits + """ + + # Setup subunits with dummy value, but it is good enough for building sourcelist + for input_subunit in INPUT_SUBUNITS: + setattr(mock_ynca, input_subunit, True) + + mapping = InputHelper.get_source_mapping(mock_ynca) + + assert mapping[ynca.Input.AIRPLAY] == "Airplay" + assert mapping[ynca.Input.BLUETOOTH] == "Bluetooth" + assert mapping[ynca.Input.IPOD] == "iPod" + assert mapping[ynca.Input.IPOD_USB] == "iPod (USB)" + assert mapping[ynca.Input.NAPSTER] == "Napster" + assert mapping[ynca.Input.NETRADIO] == "NET RADIO" + assert mapping[ynca.Input.PANDORA] == "Pandora" + assert mapping[ynca.Input.PC] == "PC" + assert mapping[ynca.Input.RHAPSODY] == "Rhapsody" + assert mapping[ynca.Input.SERVER] == "SERVER" + assert mapping[ynca.Input.SIRIUS] == "SIRIUS" + assert mapping[ynca.Input.SIRIUS_IR] == "SIRIUS InternetRadio" + assert mapping[ynca.Input.SIRIUS_XM] == "SiriusXM" + assert mapping[ynca.Input.SPOTIFY] == "Spotify" + assert mapping[ynca.Input.TUNER] == "TUNER" + assert mapping[ynca.Input.UAW] == "UAW" + assert mapping[ynca.Input.USB] == "USB" + + +def test_sourcelist_no_duplicates(mock_ynca): + """ + Should be no duplicates, e.g. avoid USB is in the list twice + """ + + # Setup subunits with dummy value, but it is good enough for building sourcelist + for input_subunit in INPUT_SUBUNITS: + setattr(mock_ynca, input_subunit, True) + mock_sys = mock_ynca.sys + for attribute in dir(mock_sys): + if attribute.startswith("inpname"): + setattr(mock_sys, attribute, f"_{attribute.upper()}_") + + mapping = InputHelper.get_source_mapping(mock_ynca) + + assert len(mapping.values()) == len(set(mapping.values())) + + +def test_sourcelist_input_duplicates_prefer_inpname(mock_ynca): + """ + Inputs mentioned multiple times (like USB) + should use inpname over default inputsubunit name + """ + + mock_ynca.usb = True + mock_ynca.sys.inpnameusb = "_INPNAMEUSB_" + + mapping = InputHelper.get_source_mapping(mock_ynca) + + assert mapping[ynca.Input.USB] == "_INPNAMEUSB_" + + +def test_get_name_of_input(mock_ynca): + + mock_ynca.sys.inpnameusb = "_INPNAMEUSB_" + + # Available input + name = InputHelper.get_name_of_input(mock_ynca, ynca.Input.USB) + assert name == "_INPNAMEUSB_" + + # Unavailable input + name = InputHelper.get_name_of_input(mock_ynca, ynca.Input.HDMI1) + assert name is None + + +def test_get_input_by_name(mock_ynca): + + mock_ynca.sys.inpnameusb = "_INPNAMEUSB_" + + # Available input + input = InputHelper.get_input_by_name(mock_ynca, "_INPNAMEUSB_") + assert input is ynca.Input.USB + + # Unavailable input + input = InputHelper.get_input_by_name(mock_ynca, "Unknown") + assert input is None + + +def test_get_subunit_for_input(mock_ynca): + + mock_ynca.usb = True + + # Available subunit + subunit = InputHelper.get_subunit_for_input(mock_ynca, ynca.Input.USB) + assert subunit is mock_ynca.usb + + # Unavailable subunit + subunit = InputHelper.get_subunit_for_input(mock_ynca, ynca.Input.AIRPLAY) + assert subunit is None + + # Unavailable subunit because not related to a subunit + subunit = InputHelper.get_subunit_for_input(mock_ynca, ynca.Input.HDMI6) + assert subunit is None diff --git a/tests/test_media_player.py b/tests/test_media_player.py index 28f076c..b666dbb 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -1,5 +1,5 @@ """Test the Yamaha (YNCA) config flow.""" -from unittest.mock import Mock, create_autospec, patch +from unittest.mock import Mock, call, create_autospec, patch import pytest from homeassistant.components.media_player import ( @@ -9,35 +9,70 @@ RepeatMode, ) - import ynca import custom_components.yamaha_ynca as yamaha_ynca -from custom_components.yamaha_ynca.media_player import YamahaYncaZone +from custom_components.yamaha_ynca.media_player import YamahaYncaZone, async_setup_entry +from tests.conftest import setup_integration @pytest.fixture def mock_zone(): """Create a mocked YNCA Zone instance.""" zone = Mock( - spec=ynca.zone.ZoneBase, + spec=ynca.subunits.zone.ZoneBase, ) zone.id = "ZoneId" zone.zonename = "ZoneName" - zone.scene_names = {"1234": "SceneName 1234"} + zone.scene1name = "SceneName One" zone.maxvol = 10 - zone.inp = "INPUT_ID_1" + zone.inp = ynca.Input.HDMI1 return zone @pytest.fixture -def mp_entity(mock_zone, mock_ynca): +def mp_entity(mock_zone, mock_ynca) -> YamahaYncaZone: return YamahaYncaZone("ReceiverUniqueId", mock_ynca, mock_zone, [], []) -@patch("ynca.get_inputinfo_list", return_value=[]) -async def test_mediaplayer_entity(patched_get_inputinfo_list, mp_entity, mock_zone): +@patch("custom_components.yamaha_ynca.media_player.YamahaYncaZone", autospec=True) +async def test_async_setup_entry( + yamahayncazone_mock, + hass, + mock_ynca, +): + + mock_ynca.main = Mock(spec=ynca.subunits.zone.Main) + mock_ynca.zone2 = Mock(spec=ynca.subunits.zone.Zone2) + + mock_ynca.main.zonename = "_MAIN_" + mock_ynca.zone2.zonename = "_ZONE2_" + + integration = await setup_integration(hass, mock_ynca, modelname="RX-A810") + integration.entry.options = { + "hidden_sound_modes": ["Adventure"], + "hidden_inputs_MAIN": ["Airplay"], + } + add_entities_mock = Mock() + + await async_setup_entry(hass, integration.entry, add_entities_mock) + + yamahayncazone_mock.assert_has_calls( + [ + call("entry_id", mock_ynca, mock_ynca.main, ["Airplay"], ["Adventure"]), + call("entry_id", mock_ynca, mock_ynca.zone2, [], ["Adventure"]), + ] + ) + + add_entities_mock.assert_called_once() + entities = add_entities_mock.call_args.args[0] + assert len(entities) == 2 + + +async def test_mediaplayer_entity(mp_entity, mock_zone, mock_ynca): + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) + assert mp_entity.unique_id == "ReceiverUniqueId_ZoneId" assert mp_entity.device_info["identifiers"] == { (yamaha_ynca.DOMAIN, "ReceiverUniqueId") @@ -46,10 +81,22 @@ async def test_mediaplayer_entity(patched_get_inputinfo_list, mp_entity, mock_zo await mp_entity.async_added_to_hass() mock_zone.register_update_callback.assert_called_once() + mock_ynca.netradio.register_update_callback.assert_called_once() + + zone_callback = mock_zone.register_update_callback.call_args.args[0] + netradio_callback = mock_ynca.netradio.register_update_callback.call_args.args[0] + mp_entity.schedule_update_ha_state = Mock() + + zone_callback("FUNCTION", "VALUE") + mp_entity.schedule_update_ha_state.call_count == 1 + + netradio_callback("FUNCTION", "VALUE") + mp_entity.schedule_update_ha_state.call_count == 2 await mp_entity.async_will_remove_from_hass() - mock_zone.unregister_update_callback.assert_called_once_with( - mock_zone.register_update_callback.call_args.args[0] + mock_zone.unregister_update_callback.assert_called_once_with(zone_callback) + mock_ynca.netradio.unregister_update_callback.assert_called_once_with( + netradio_callback ) @@ -63,29 +110,27 @@ async def test_mediaplayer_entity_name( assert mp_entity.name == "ZoneId" -@patch("ynca.get_inputinfo_list", return_value=[]) async def test_mediaplayer_entity_turn_on_off( - patched_get_inputinfo_list, - mp_entity, + mp_entity: YamahaYncaZone, mock_zone, ): mp_entity.turn_on() - assert mock_zone.pwr == True - assert mp_entity.state == MediaPlayerState.IDLE + assert mock_zone.pwr is ynca.Pwr.ON + assert mp_entity.state is MediaPlayerState.IDLE mp_entity.turn_off() - assert mock_zone.pwr == False - assert mp_entity.state == MediaPlayerState.OFF + assert mock_zone.pwr is ynca.Pwr.STANDBY + assert mp_entity.state is MediaPlayerState.OFF async def test_mediaplayer_entity_mute_volume(mp_entity, mock_zone): mp_entity.mute_volume(True) - assert mock_zone.mute == ynca.Mute.on + assert mock_zone.mute is ynca.Mute.ON assert mp_entity.is_volume_muted == True mp_entity.mute_volume(False) - assert mock_zone.mute == ynca.Mute.off + assert mock_zone.mute is ynca.Mute.OFF assert mp_entity.is_volume_muted == False @@ -107,49 +152,61 @@ async def test_mediaplayer_entity_volume_set_up_down(mp_entity, mock_zone): async def test_mediaplayer_entity_source(mock_zone, mock_ynca): - with patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(None, "INPUT_ID_1", "Input Name 1"), - ynca.InputInfo(None, "INPUT_ID_2", "Input Name 2"), - ], - ): - mp_entity = YamahaYncaZone( - "ReceiverUniqueId", mock_ynca, mock_zone, ["INPUT_ID_1"], [] - ) - assert mp_entity.source_list == ["Input Name 2"] + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) + mock_ynca.sys.inpnamehdmi4 = "Input HDMI 4" + + mp_entity = YamahaYncaZone("ReceiverUniqueId", mock_ynca, mock_zone, [], []) + + # Select a rename-able source + mp_entity.select_source("Input HDMI 4") + assert mock_zone.inp is ynca.Input.HDMI4 + assert mp_entity.source == "Input HDMI 4" - mp_entity.select_source("Input Name 2") - assert mock_zone.inp == "INPUT_ID_2" - assert mp_entity.source == "Input Name 2" + # Select a source that maps to built in subunit + mp_entity.select_source("NET RADIO") + assert mock_zone.inp is ynca.Input.NETRADIO + assert mp_entity.source == "NET RADIO" - mp_entity.select_source("invalid source") # does not change current source - assert mock_zone.inp == "INPUT_ID_2" - assert mp_entity.source == "Input Name 2" + # Invalid source does not change input + mp_entity.select_source("invalid source") + assert mock_zone.inp is ynca.Input.NETRADIO + assert mp_entity.source == "NET RADIO" - # Input without mapped name shows as ID - mock_zone.inp = "INPUT_ID_WITHOUT_NAME" - assert mp_entity.source == "INPUT_ID_WITHOUT_NAME" + # Input without mapped name shows as Unknown + mock_zone.inp = ynca.Input.SIRIUS + assert mp_entity.source == "Unknown" + + +async def test_mediaplayer_entity_source_list(mock_zone, mock_ynca): + + mock_ynca.tun = create_autospec(ynca.subunits.tun.Tun) + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) + mock_ynca.sys.inpnamehdmi4 = "Input HDMI 4" + + mp_entity = YamahaYncaZone("ReceiverUniqueId", mock_ynca, mock_zone, ["TUNER"], []) + + print(mp_entity.source_list) + assert mp_entity.source_list == ["Input HDMI 4", "NET RADIO"] async def test_mediaplayer_entity_sound_mode(mp_entity, mock_zone): - mp_entity.select_sound_mode("Sound mode 2") - assert mock_zone.soundprg == "Sound mode 2" - assert mock_zone.straight == False - assert mp_entity.sound_mode == "Sound mode 2" + mp_entity.select_sound_mode("Village Vanguard") + assert mock_zone.soundprg is ynca.SoundPrg.VILLAGE_VANGUARD + assert mock_zone.straight is ynca.Straight.OFF + assert mp_entity.sound_mode == "Village Vanguard" # Straight is special as it is a separate setting on the Zone mp_entity.select_sound_mode("Straight") - assert mock_zone.soundprg == "Sound mode 2" - assert mock_zone.straight == True + assert mock_zone.soundprg is ynca.SoundPrg.VILLAGE_VANGUARD + assert mock_zone.straight is ynca.Straight.ON assert mp_entity.sound_mode == "Straight" async def test_mediaplayer_entity_sound_mode_list(mp_entity, mock_zone): - mock_zone.straight = False + mock_zone.straight = ynca.Straight.OFF assert "Straight" in mp_entity.sound_mode_list mock_zone.straight = None @@ -158,42 +215,34 @@ async def test_mediaplayer_entity_sound_mode_list(mp_entity, mock_zone): mock_zone.soundprg = None assert mp_entity.sound_mode_list is None - mock_zone.soundprg = "DspSoundProgram" - assert mp_entity.sound_mode_list == sorted(ynca.SoundPrg) + mock_zone.soundprg = ynca.SoundPrg.CELLAR_CLUB + assert mp_entity.sound_mode_list == sorted( + [sp for sp in ynca.SoundPrg if sp is not ynca.SoundPrg.UNKNOWN] + ) @patch( - "ynca.get_modelinfo", + "ynca.YncaModelInfo.get", return_value=ynca.modelinfo.ModelInfo(soundprg=[ynca.SoundPrg.ALL_CH_STEREO]), ) async def test_mediaplayer_entity_sound_mode_list_from_modelinfo( - patched_get_modelinfo, mp_entity, mock_zone + patched_YncaModelInfo_get, mp_entity, mock_zone ): - mock_zone.soundprg = "DspSoundProgram" + mock_zone.soundprg = ynca.SoundPrg.MONO_MOVIE assert "All-Ch Stereo" in mp_entity.sound_mode_list async def test_mediaplayer_entity_hidden_sound_mode(mock_ynca, mock_zone): mp_entity = YamahaYncaZone( - "ReceiverUniqueId", mock_ynca, mock_zone, ["INPUT_ID_1"], ["MONO_MOVIE"] + "ReceiverUniqueId", mock_ynca, mock_zone, [], ["MONO_MOVIE"] ) assert "Drama" in mp_entity.sound_mode_list assert "Mono movie" not in mp_entity.sound_mode_list -@patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(ynca.Subunit.USB, "USB", "USB"), - ynca.InputInfo(ynca.Subunit.NETRADIO, "NET RADIO", "NET RADIO"), - ynca.InputInfo(ynca.Subunit.SPOTIFY, "SPOTIFY", "SPOTIFY"), - ], -) -async def test_mediaplayer_entity_supported_features( - patched_get_inputinfo_list, mp_entity, mock_zone, mock_ynca -): +async def test_mediaplayer_entity_supported_features(mp_entity, mock_zone, mock_ynca): expected_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET @@ -207,73 +256,57 @@ async def test_mediaplayer_entity_supported_features( mock_zone.soundprg = None assert mp_entity.supported_features == expected_supported_features - mock_zone.soundprg = "DspSoundProgram" + mock_zone.soundprg = ynca.SoundPrg.ACTION_GAME expected_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE assert mp_entity.supported_features == expected_supported_features # Sources with `playback` attribute support playback controls # Radio sources only support play and stop - mock_ynca.NETRADIO = create_autospec( - ynca.netradio.NetRadio, id=ynca.Subunit.NETRADIO - ) - mock_zone.inp = "NET RADIO" + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) + mock_zone.inp = ynca.Input.NETRADIO expected_supported_features |= MediaPlayerEntityFeature.PLAY expected_supported_features |= MediaPlayerEntityFeature.STOP assert mp_entity.supported_features == expected_supported_features # Other sources support pause, previous, next # Repeat/shuffle capability depends on availability of repeat/shuffle attributes on YNCA subunit - mock_ynca.SPOTIFY = create_autospec( - ynca.mediaplayback_subunits.Spotify, id=ynca.Subunit.SPOTIFY - ) - mock_ynca.SPOTIFY.repeat = None - mock_ynca.SPOTIFY.shuffle = None - mock_zone.inp = "SPOTIFY" + mock_ynca.spotify = create_autospec(ynca.subunits.mediaplayback_subunits.Spotify) + mock_ynca.spotify.repeat = None + mock_ynca.spotify.shuffle = None + mock_zone.inp = ynca.Input.SPOTIFY expected_supported_features |= MediaPlayerEntityFeature.PAUSE expected_supported_features |= MediaPlayerEntityFeature.PREVIOUS_TRACK expected_supported_features |= MediaPlayerEntityFeature.NEXT_TRACK assert mp_entity.supported_features == expected_supported_features # USB also supports repeat and shuffle - mock_ynca.USB = create_autospec( - ynca.mediaplayback_subunits.Usb, id=ynca.Subunit.USB - ) - mock_zone.inp = "USB" + mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) + mock_zone.inp = ynca.Input.USB expected_supported_features |= MediaPlayerEntityFeature.REPEAT_SET expected_supported_features |= MediaPlayerEntityFeature.SHUFFLE_SET assert mp_entity.supported_features == expected_supported_features -@patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(ynca.Subunit.USB, "USB", "USB"), - ], -) -async def test_mediaplayer_entity_state( - patched_get_inputinfo_list, mp_entity, mock_zone, mock_ynca -): +async def test_mediaplayer_entity_state(mp_entity, mock_zone, mock_ynca): - mock_zone.pwr = False - assert mp_entity.state == MediaPlayerState.OFF + mock_zone.pwr = ynca.Pwr.STANDBY + assert mp_entity.state is MediaPlayerState.OFF - mock_zone.pwr = True - assert mp_entity.state == MediaPlayerState.IDLE + mock_zone.pwr = ynca.Pwr.ON + assert mp_entity.state is MediaPlayerState.IDLE - mock_zone.inp = "USB" - mock_ynca.USB = create_autospec( - ynca.mediaplayback_subunits.Usb, id=ynca.Subunit.USB - ) + mock_zone.inp = ynca.Input.USB + mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) - mock_ynca.USB.playbackinfo = ynca.PlaybackInfo.PLAY - assert mp_entity.state == MediaPlayerState.PLAYING + mock_ynca.usb.playbackinfo = ynca.PlaybackInfo.PLAY + assert mp_entity.state is MediaPlayerState.PLAYING - mock_ynca.USB.playbackinfo = ynca.PlaybackInfo.PAUSE - assert mp_entity.state == MediaPlayerState.PAUSED + mock_ynca.usb.playbackinfo = ynca.PlaybackInfo.PAUSE + assert mp_entity.state is MediaPlayerState.PAUSED - mock_ynca.USB.playbackinfo = ynca.PlaybackInfo.STOP - assert mp_entity.state == MediaPlayerState.IDLE + mock_ynca.usb.playbackinfo = ynca.PlaybackInfo.STOP + assert mp_entity.state is MediaPlayerState.IDLE async def test_mediaplayer_playback_controls(mp_entity, mock_zone): @@ -289,20 +322,7 @@ async def test_mediaplayer_playback_controls(mp_entity, mock_zone): mock_zone.playback.assert_called_with(ynca.Playback.SKIP_REV) -@patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(ynca.Subunit.USB, "USB", "USB"), - ynca.InputInfo(ynca.Subunit.NETRADIO, "NET RADIO", "NET RADIO"), - ynca.InputInfo(ynca.Subunit.TUN, "TUNER", "TUNER"), - ynca.InputInfo( - ynca.Subunit.SIRIUSIR, "SIRIUS InternetRadio", "SIRIUS InternetRadio" - ), - ], -) -async def test_mediaplayer_mediainfo( - patched_get_inputinfo_list, mp_entity, mock_zone, mock_ynca -): +async def test_mediaplayer_mediainfo(mp_entity, mock_zone, mock_ynca): assert mp_entity.media_album_name is None assert mp_entity.media_artist is None @@ -311,121 +331,91 @@ async def test_mediaplayer_mediainfo( assert mp_entity.media_content_type is None # Some subunits support Music with Artist, Album, Song - mock_zone.inp = "USB" - mock_ynca.USB = create_autospec( - ynca.mediaplayback_subunits.Usb, id=ynca.Subunit.USB - ) + mock_zone.inp = ynca.Input.USB + mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) - mock_ynca.USB.album = "AlbumName" - mock_ynca.USB.artist = "ArtistName" - mock_ynca.USB.song = "Title" + mock_ynca.usb.album = "AlbumName" + mock_ynca.usb.artist = "ArtistName" + mock_ynca.usb.song = "Title" assert mp_entity.media_album_name == "AlbumName" assert mp_entity.media_artist == "ArtistName" assert mp_entity.media_title == "Title" assert mp_entity.media_content_type is MediaType.MUSIC # Netradio is a "channel" which name is exposed by the "station" attribute - mock_zone.inp = "NET RADIO" - mock_ynca.NETRADIO = create_autospec( - ynca.netradio.NetRadio, id=ynca.Subunit.NETRADIO - ) - mock_ynca.NETRADIO.station = "StationName" + mock_zone.inp = ynca.Input.NETRADIO + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) + mock_ynca.netradio.station = "StationName" assert mp_entity.media_channel == "StationName" assert mp_entity.media_content_type is MediaType.CHANNEL # Tuner (analog radio) is a "channel" - # There is no station name, so build name from band and frequency - mock_zone.inp = "TUNER" - mock_ynca.TUN = create_autospec(ynca.tun.Tun, id=ynca.Subunit.TUN) - mock_ynca.TUN.band = ynca.Band.FM - mock_ynca.TUN.fmfreq = 123.45 + # There is no station name, so name is built from band and frequency + mock_zone.inp = ynca.Input.TUNER + mock_ynca.tun = create_autospec(ynca.subunits.tun.Tun) + mock_ynca.tun.band = ynca.BandTun.FM + mock_ynca.tun.fmfreq = 123.45 assert mp_entity.media_channel == "FM 123.45 MHz" assert mp_entity.media_content_type is MediaType.CHANNEL - mock_ynca.TUN.band = ynca.Band.AM - mock_ynca.TUN.amfreq = 1234 + mock_ynca.tun.band = ynca.BandTun.AM + mock_ynca.tun.amfreq = 1234 assert mp_entity.media_channel == "AM 1234 kHz" assert mp_entity.media_content_type is MediaType.CHANNEL # Sirius subunits expose name by the "chname" attribute - mock_zone.inp = "SIRIUS InternetRadio" - mock_ynca.SIRIUSIR = create_autospec(ynca.sirius.SiriusIr, id=ynca.Subunit.SIRIUSIR) - mock_ynca.SIRIUSIR.chname = "ChannelName" + mock_zone.inp = ynca.Input.SIRIUS_IR + mock_ynca.siriusir = create_autospec(ynca.subunits.sirius.SiriusIr) + mock_ynca.siriusir.chname = "ChannelName" assert mp_entity.media_channel == "ChannelName" assert mp_entity.media_content_type is MediaType.CHANNEL -@patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(ynca.Subunit.USB, "USB", "USB"), - ynca.InputInfo(ynca.Subunit.NETRADIO, "NET RADIO", "NET RADIO"), - ], -) -async def test_mediaplayer_entity_shuffle( - patched_get_inputinfo_list, mp_entity, mock_zone, mock_ynca -): +async def test_mediaplayer_entity_shuffle(mp_entity, mock_zone, mock_ynca): # Unsupported subunit selected - assert mp_entity.shuffle == None + assert mp_entity.shuffle is None # Subunit supporting shuffle - mock_zone.inp = "USB" - mock_ynca.USB = create_autospec( - ynca.mediaplayback_subunits.Usb, id=ynca.Subunit.USB - ) + mock_zone.inp = ynca.Input.USB + mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) mp_entity.set_shuffle(True) - assert mock_ynca.USB.shuffle == True + assert mock_ynca.usb.shuffle is ynca.Shuffle.ON assert mp_entity.shuffle == True mp_entity.set_shuffle(False) - assert mock_ynca.USB.shuffle == False + assert mock_ynca.usb.shuffle is ynca.Shuffle.OFF assert mp_entity.shuffle == False # Subunit not supporting shuffle - mock_zone.inp = "NET RADIO" - mock_ynca.NETRADIO = create_autospec( - ynca.netradio.NetRadio, id=ynca.Subunit.NETRADIO - ) - assert mp_entity.shuffle == None + mock_zone.inp = ynca.Input.NETRADIO + mock_ynca.netradio = create_autospec(ynca.subunits.netradio.NetRadio) + assert mp_entity.shuffle is None -@patch( - "ynca.get_inputinfo_list", - return_value=[ - ynca.InputInfo(ynca.Subunit.USB, "USB", "USB"), - ynca.InputInfo(ynca.Subunit.NETRADIO, "NET RADIO", "NET RADIO"), - ], -) -async def test_mediaplayer_entity_repeat( - patched_get_inputinfo_list, mp_entity, mock_zone, mock_ynca -): +async def test_mediaplayer_entity_repeat(mp_entity, mock_zone, mock_ynca): # Unsupported subunit selected - assert mp_entity.repeat == None + assert mp_entity.repeat is None # Subunit supporting repeat - mock_zone.inp = "USB" - mock_ynca.USB = create_autospec( - ynca.mediaplayback_subunits.Usb, id=ynca.Subunit.USB - ) + mock_zone.inp = ynca.Input.USB + mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) mp_entity.set_repeat(RepeatMode.OFF) - assert mock_ynca.USB.repeat == ynca.Repeat.OFF - assert mp_entity.repeat == RepeatMode.OFF + assert mock_ynca.usb.repeat is ynca.Repeat.OFF + assert mp_entity.repeat is RepeatMode.OFF mp_entity.set_repeat(RepeatMode.ONE) - assert mock_ynca.USB.repeat == ynca.Repeat.SINGLE - assert mp_entity.repeat == RepeatMode.ONE + assert mock_ynca.usb.repeat is ynca.Repeat.SINGLE + assert mp_entity.repeat is RepeatMode.ONE mp_entity.set_repeat(RepeatMode.ALL) - assert mock_ynca.USB.repeat == ynca.Repeat.ALL - assert mp_entity.repeat == RepeatMode.ALL + assert mock_ynca.usb.repeat is ynca.Repeat.ALL + assert mp_entity.repeat is RepeatMode.ALL # Subunit not supporting repeat - mock_zone.inp = "NET RADIO" - mock_ynca.NETRADIO = create_autospec( - ynca.netradio.NetRadio, id=ynca.Subunit.NETRADIO - ) - assert mp_entity.repeat == None + mock_zone.inp = ynca.Input.NETRADIO + mock_ynca.NETRADIO = create_autospec(ynca.subunits.netradio.NetRadio) + assert mp_entity.repeat is None From 3c77593f7118ecc9149f77014af2e6d7f1a2d9b4 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sun, 27 Nov 2022 12:34:08 +0100 Subject: [PATCH 2/6] Disable dependabot (#87) --- .github/dependabot.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index ba1c6b8..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,11 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates - -version: 2 -updates: - - package-ecosystem: "pip" # See documentation for possible values - directory: "/" # Location of package manifests - schedule: - interval: "daily" From d08aa3dc4e4cb621cb2b21db9e870613a69f96ef Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sun, 27 Nov 2022 12:59:31 +0100 Subject: [PATCH 3/6] Update README.md (#88) --- README.md | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ec9b6ef..0b4e9cc 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Custom integration for Home Assistant to support Yamaha AV receivers with the YNCA protocol (serial and IP). -According to the protocol documentation the following AV receivers should be supported (not all tested), there might be more. +According to reports for users and info found on the internet the following AV receivers should be supported (not all tested), there might be more. If your receiver works and is not in the list please let us know! > RX-A700, RX-A710, RX-A800, RX-A810, RX-A840, RX-A850, RX-A1000, RX-A1010, RX-A1040, RX-A2000, RX-A2010, RX-A3000, RX-A3010, RX-V475, RX-V671, RX-V673, RX-V867, RX-V871, RX-V1067, RX-V2067, RX-V3067, TSR-700 @@ -30,31 +30,33 @@ It is not possible to autodetect all features of a receiver. However there are s ### Scene buttons not working -For some receivers (e.g. RX-V475) the command to activate the scenes does not work even though scenes are supported by the receiver. As a workaround, just hide the scene button entities in Home Assistant +For some receivers (e.g. RX-V475) the command to activate the scenes does not work even though scenes are supported by the receiver. As a workaround, hide the scene button entities in Home Assistant ### Inputs do not match zone -It is only possible to detect all possible inputs on the receiver, not which ones work which what zones. +For most receivers the inputs available on the receiver can be detected, but it is not possible to detect which of those inputs are available per zone. -You can hide the inputs per zone in the integration configuration which can be accessed by pressing the "Configure" button on the integration card in the "Devices & Services" section of the Home Assistant settings. +You can select the inputs per zone in the integration configuration which can be accessed by pressing the "Configure" button on the integration card in the "Devices & Services" section of the Home Assistant settings. ### Soundmodes do not match receiver -Since the list of soundmodes can not be detected by default the whole list of known soundmodes is shown. -You can hide the soundmodes that do not apply to the receiver in the integration configuration which can be accessed by pressing the "Configure" button on the integration card in the "Devices & Services" section of the Home Assistant settings. +The list of soundmodes can not be detected, so by default the whole list of known soundmodes is shown. + +You can select the soundmodes applicable the specific receiver in the integration configuration which can be accessed by pressing the "Configure" button on the integration card in the "Devices & Services" section of the Home Assistant settings. ## Installation ### HACS -Recommended as you get notified of updates. +*Recommended as you get notified of updates.* * Add integration within HACS (use the + button and search for "YNCA") * Restart Home Assistant -* Go to the Home Assistant integrations menu and press the Add button and search for "Yamaha (YNCA)" +* Go to the Home Assistant integrations menu and press the Add button and search for "Yamaha (YNCA)". You might need to clear the browser cache for it to show up (e.g. reload with CTRL+F5). ### Manual -* Install the custom component by downloading it and copy it to the `custom_components` directory as usual. +* Install the custom component by downloading the zipfile from the release +* Extract the zip and copy the contents to the `custom_components` directory as usual. * Restart Home Assistant -* Go to the Home Assistant integrations menu and press the Add button and search for "Yamaha (YNCA)" +* Go to the Home Assistant integrations menu and press the Add button and search for "Yamaha (YNCA)". You might need to clear the browser cache for it to show up (e.g. reload with CTRL+F5). From 1c7fea73922038c79c00ef0aa1214248b577d397 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sun, 27 Nov 2022 13:00:44 +0100 Subject: [PATCH 4/6] Update README.md (#89) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0b4e9cc..539bae7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Custom integration for Home Assistant to support Yamaha AV receivers with the YNCA protocol (serial and IP). -According to reports for users and info found on the internet the following AV receivers should be supported (not all tested), there might be more. If your receiver works and is not in the list please let us know! +According to reports of users and info found on the internet the following AV receivers should be supported (not all tested), there might be more. If your receiver works and is not in the list please let us know! > RX-A700, RX-A710, RX-A800, RX-A810, RX-A840, RX-A850, RX-A1000, RX-A1010, RX-A1040, RX-A2000, RX-A2010, RX-A3000, RX-A3010, RX-V475, RX-V671, RX-V673, RX-V867, RX-V871, RX-V1067, RX-V2067, RX-V3067, TSR-700 From 642074b32838dd5ab973ee4b7bbfff137a04f083 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 2 Dec 2022 13:33:02 +0100 Subject: [PATCH 5/6] Bump ynca to 4.0.0 (#90) * Bump ynca to 4.0.0 * Update tests * Bump ynca to 4.0.1 --- custom_components/yamaha_ynca/manifest.json | 2 +- requirements.txt | 2 +- tests/test_media_player.py | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/custom_components/yamaha_ynca/manifest.json b/custom_components/yamaha_ynca/manifest.json index f4cef27..d47e754 100644 --- a/custom_components/yamaha_ynca/manifest.json +++ b/custom_components/yamaha_ynca/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://github.com/mvdwetering/yamaha_ynca", "issue_tracker": "https://github.com/mvdwetering/yamaha_ynca/issues", "requirements": [ - "ynca==4.0.0b2" + "ynca==4.0.1" ], "ssdp": [], "zeroconf": [], diff --git a/requirements.txt b/requirements.txt index ca37e51..803860d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -ynca==4.0.0b2 \ No newline at end of file +ynca==4.0.1 \ No newline at end of file diff --git a/tests/test_media_player.py b/tests/test_media_player.py index b666dbb..244f368 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -271,7 +271,7 @@ async def test_mediaplayer_entity_supported_features(mp_entity, mock_zone, mock_ # Other sources support pause, previous, next # Repeat/shuffle capability depends on availability of repeat/shuffle attributes on YNCA subunit - mock_ynca.spotify = create_autospec(ynca.subunits.mediaplayback_subunits.Spotify) + mock_ynca.spotify = create_autospec(ynca.subunits.spotify.Spotify) mock_ynca.spotify.repeat = None mock_ynca.spotify.shuffle = None mock_zone.inp = ynca.Input.SPOTIFY @@ -281,7 +281,7 @@ async def test_mediaplayer_entity_supported_features(mp_entity, mock_zone, mock_ assert mp_entity.supported_features == expected_supported_features # USB also supports repeat and shuffle - mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) + mock_ynca.usb = create_autospec(ynca.subunits.usb.Usb) mock_zone.inp = ynca.Input.USB expected_supported_features |= MediaPlayerEntityFeature.REPEAT_SET expected_supported_features |= MediaPlayerEntityFeature.SHUFFLE_SET @@ -297,7 +297,7 @@ async def test_mediaplayer_entity_state(mp_entity, mock_zone, mock_ynca): assert mp_entity.state is MediaPlayerState.IDLE mock_zone.inp = ynca.Input.USB - mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) + mock_ynca.usb = create_autospec(ynca.subunits.usb.Usb) mock_ynca.usb.playbackinfo = ynca.PlaybackInfo.PLAY assert mp_entity.state is MediaPlayerState.PLAYING @@ -332,7 +332,7 @@ async def test_mediaplayer_mediainfo(mp_entity, mock_zone, mock_ynca): # Some subunits support Music with Artist, Album, Song mock_zone.inp = ynca.Input.USB - mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) + mock_ynca.usb = create_autospec(ynca.subunits.usb.Usb) mock_ynca.usb.album = "AlbumName" mock_ynca.usb.artist = "ArtistName" @@ -378,7 +378,7 @@ async def test_mediaplayer_entity_shuffle(mp_entity, mock_zone, mock_ynca): # Subunit supporting shuffle mock_zone.inp = ynca.Input.USB - mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) + mock_ynca.usb = create_autospec(ynca.subunits.usb.Usb) mp_entity.set_shuffle(True) assert mock_ynca.usb.shuffle is ynca.Shuffle.ON @@ -401,7 +401,7 @@ async def test_mediaplayer_entity_repeat(mp_entity, mock_zone, mock_ynca): # Subunit supporting repeat mock_zone.inp = ynca.Input.USB - mock_ynca.usb = create_autospec(ynca.subunits.mediaplayback_subunits.Usb) + mock_ynca.usb = create_autospec(ynca.subunits.usb.Usb) mp_entity.set_repeat(RepeatMode.OFF) assert mock_ynca.usb.repeat is ynca.Repeat.OFF From e75c01721e3e0224c9a91b8be4af68de31d99b0a Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 2 Dec 2022 13:37:26 +0100 Subject: [PATCH 6/6] Update version to 5.2.0 --- custom_components/yamaha_ynca/manifest.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/custom_components/yamaha_ynca/manifest.json b/custom_components/yamaha_ynca/manifest.json index d47e754..6f5c550 100644 --- a/custom_components/yamaha_ynca/manifest.json +++ b/custom_components/yamaha_ynca/manifest.json @@ -15,6 +15,8 @@ "@mvdwetering" ], "iot_class": "local_push", - "loggers": ["ynca"], - "version": "0.0.0" + "loggers": [ + "ynca" + ], + "version": "5.2.0" }