From bfbf572d63142a3c10137d54e860f30afc4bcf77 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Mon, 22 Jul 2024 20:26:24 +0200 Subject: [PATCH 01/16] Register services in async setup (#299) --- custom_components/yamaha_ynca/__init__.py | 35 ++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/custom_components/yamaha_ynca/__init__.py b/custom_components/yamaha_ynca/__init__.py index dca6244..561d0b9 100644 --- a/custom_components/yamaha_ynca/__init__.py +++ b/custom_components/yamaha_ynca/__init__.py @@ -1,4 +1,5 @@ """The Yamaha (YNCA) integration.""" + from __future__ import annotations import asyncio @@ -11,8 +12,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry +from homeassistant.helpers import config_validation as cv, device_registry from homeassistant.helpers.service import ServiceCall, async_extract_config_entry_ids +from homeassistant.helpers.typing import ConfigType from .const import ( COMMUNICATION_LOG_SIZE, @@ -36,6 +38,7 @@ Platform.REMOTE, ] + async def update_device_registry( hass: HomeAssistant, config_entry: ConfigEntry, receiver: ynca.YncaApi ): @@ -111,10 +114,26 @@ async def async_handle_send_raw_ynca(hass: HomeAssistant, call: ServiceCall): if domain_entry_info := hass.data[DOMAIN].get(config_entry_id, None): for line in call.data.get("raw_data").splitlines(): line = line.strip() - if line.startswith('@'): + if line.startswith("@"): domain_entry_info.api.send_raw(line) +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Yamaha (YNCA) integration.""" + + async def async_handle_send_raw_ynca_local(call: ServiceCall): + await async_handle_send_raw_ynca(hass, call) + + hass.services.async_register( + DOMAIN, SERVICE_SEND_RAW_YNCA, async_handle_send_raw_ynca_local + ) + + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Yamaha (YNCA) from a config entry.""" @@ -164,7 +183,7 @@ def on_disconnect(): await update_device_registry(hass, entry, ynca_receiver) await update_configentry(hass, entry, ynca_receiver) - assert(ynca_receiver.sys is not None) + assert ynca_receiver.sys is not None if receiver_requires_audio_input_workaround(ynca_receiver.sys.modelname): # Pretend AUDIO provides a name like a normal input # This makes it work with standard code @@ -176,15 +195,6 @@ def on_disconnect(): ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - if not hass.services.has_service(DOMAIN, SERVICE_SEND_RAW_YNCA): - - async def async_handle_send_raw_ynca_local(call: ServiceCall): - await async_handle_send_raw_ynca(hass, call) - - hass.services.async_register( - DOMAIN, SERVICE_SEND_RAW_YNCA, async_handle_send_raw_ynca_local - ) - entry.async_on_unload(entry.add_update_listener(async_update_options)) return initialized @@ -202,6 +212,5 @@ def close_ynca(ynca_receiver: ynca.YncaApi): if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) - hass.services.async_remove(DOMAIN, SERVICE_SEND_RAW_YNCA) return unload_ok From 769ee720d8ef8ad8614c73ab732c51391f2f4d4b Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Mon, 22 Jul 2024 20:35:02 +0200 Subject: [PATCH 02/16] Debug log for ynca version and location (#300) --- custom_components/yamaha_ynca/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/custom_components/yamaha_ynca/__init__.py b/custom_components/yamaha_ynca/__init__.py index 561d0b9..f0abda0 100644 --- a/custom_components/yamaha_ynca/__init__.py +++ b/custom_components/yamaha_ynca/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from importlib.metadata import version import re from typing import List @@ -124,6 +125,14 @@ async def async_handle_send_raw_ynca(hass: HomeAssistant, call: ServiceCall): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Yamaha (YNCA) integration.""" + # Retrieval of version information has blocking IO so run in executor + def print_ynca_package_info(): + LOGGER.debug( + "ynca package info, version %s, location %s", version("ynca"), ynca.__file__ + ) + + await hass.async_add_executor_job(print_ynca_package_info) + async def async_handle_send_raw_ynca_local(call: ServiceCall): await async_handle_send_raw_ynca(hass, call) From d3fcedd6701c3dfec6d20505dfaccc27a493b67b Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Tue, 23 Jul 2024 22:24:32 +0200 Subject: [PATCH 03/16] Support HDMIOUT command for receivers with one port (#301) * Update select so it only has options for 2 HDMI outputs * Add HDMI Out switch for single HDMI OUT * Bump ynca version to 5.16.0 --- custom_components/yamaha_ynca/manifest.json | 2 +- custom_components/yamaha_ynca/select.py | 17 +++++++++-- custom_components/yamaha_ynca/strings.json | 3 ++ custom_components/yamaha_ynca/switch.py | 30 +++++++++++++++++-- .../yamaha_ynca/translations/en.json | 3 ++ requirements.txt | 3 +- tests/conftest.py | 2 ++ tests/test_select.py | 3 +- tests/test_switch.py | 4 ++- 9 files changed, 58 insertions(+), 9 deletions(-) diff --git a/custom_components/yamaha_ynca/manifest.json b/custom_components/yamaha_ynca/manifest.json index 975355b..83a5f00 100644 --- a/custom_components/yamaha_ynca/manifest.json +++ b/custom_components/yamaha_ynca/manifest.json @@ -12,7 +12,7 @@ "issue_tracker": "https://github.com/mvdwetering/yamaha_ynca/issues", "loggers": ["ynca"], "requirements": [ - "ynca==5.15.0" + "ynca==5.16.0" ], "version": "0.0.0" } diff --git a/custom_components/yamaha_ynca/select.py b/custom_components/yamaha_ynca/select.py index 84a5715..5c951d4 100644 --- a/custom_components/yamaha_ynca/select.py +++ b/custom_components/yamaha_ynca/select.py @@ -196,17 +196,28 @@ def is_supported(self, zone_subunit: ZoneBase): return self.supported_check(self, zone_subunit) options_fn: Callable[[ConfigEntry], List[str]] | None = None - """Override which optionns are supported for this entity.""" + """Override which options are supported for this entity.""" ENTITY_DESCRIPTIONS = [ - # Suppress following mypy message, which seems to be not an issue as other values have defaults: - # custom_components/yamaha_ynca/number.py:19: error: Missing positional arguments "entity_registry_enabled_default", "entity_registry_visible_default", "force_update", "icon", "has_entity_name", "unit_of_measurement", "max_value", "min_value", "step" in call to "NumberEntityDescription" [call-arg] YncaSelectEntityDescription( # type: ignore key="hdmiout", entity_category=EntityCategory.CONFIG, enum=ynca.HdmiOut, icon="mdi:hdmi-port", + options=[ + slugify(e.value) + for e in [ + ynca.HdmiOut.OFF, + ynca.HdmiOut.OUT1, + ynca.HdmiOut.OUT2, + ynca.HdmiOut.OUT1_PLUS_2, + ] + ], + # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output + # This select handles multiple HDMI outputs, so check if HDMI2 exists to see if it is supported + supported_check=lambda _, zone_subunit: zone_subunit.lipsynchdmiout2offset + is not None, ), YncaSelectEntityDescription( # type: ignore key="sleep", diff --git a/custom_components/yamaha_ynca/strings.json b/custom_components/yamaha_ynca/strings.json index 99ff66f..6c8bcd2 100644 --- a/custom_components/yamaha_ynca/strings.json +++ b/custom_components/yamaha_ynca/strings.json @@ -150,6 +150,9 @@ }, "threedcinema": { "name": "CINEMA DSP 3D Mode" + }, + "hdmiout": { + "name": "HDMI Out" } }, "select": { diff --git a/custom_components/yamaha_ynca/switch.py b/custom_components/yamaha_ynca/switch.py index 0d4eee5..7b3851a 100644 --- a/custom_components/yamaha_ynca/switch.py +++ b/custom_components/yamaha_ynca/switch.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass from enum import Enum -from typing import Any, List +from typing import TYPE_CHECKING, Any, Callable, List import ynca @@ -14,6 +14,8 @@ from .const import DOMAIN, ZONE_ATTRIBUTE_NAMES from .helpers import DomainEntryData, YamahaYncaSettingEntity +if TYPE_CHECKING: # pragma: no cover + from ynca.subunits.zone import ZoneBase @dataclass(frozen=True, kw_only=True) class YncaSwitchEntityDescription(SwitchEntityDescription): @@ -27,6 +29,20 @@ class YncaSwitchEntityDescription(SwitchEntityDescription): An example is HDMIOUT1 which is a function on SYS subunit, but applies to Main zone and can only be set when Main zone is On. Such relation is indicated here """ + supported_check: Callable[[YncaSwitchEntityDescription, ZoneBase], bool] = ( + lambda entity_description, zone_subunit: getattr( + zone_subunit, entity_description.key, None + ) + is not None + ) + """ + Callable to check support for this entity on the zone, default checks if attribute `key` is not None. + This _only_ works for Zone entities, not SYS. + """ + + def is_supported(self, zone_subunit: ZoneBase): + return self.supported_check(self, zone_subunit) + ZONE_ENTITY_DESCRIPTIONS = [ # Suppress following mypy message, which seems to be not an issue as other values have defaults: @@ -56,6 +72,16 @@ class YncaSwitchEntityDescription(SwitchEntityDescription): on=ynca.PureDirMode.ON, off=ynca.PureDirMode.OFF, ), + YncaSwitchEntityDescription( # type: ignore + key="hdmiout", + icon="mdi:hdmi-port", + entity_category=EntityCategory.CONFIG, + on=ynca.HdmiOut.OUT, + off=ynca.HdmiOut.OFF, + # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output + # This switch handles single HDMI output, so check if HDMI2 does NOT exist and assume there is only one HDMI output + supported_check=lambda _, zone_subunit: zone_subunit.lipsynchdmiout2offset is None + ), ] SYS_ENTITY_DESCRIPTIONS = [ @@ -87,7 +113,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for zone_attr_name in ZONE_ATTRIBUTE_NAMES: if zone_subunit := getattr(domain_entry_data.api, zone_attr_name): for entity_description in ZONE_ENTITY_DESCRIPTIONS: - if getattr(zone_subunit, entity_description.key, None) is not None: + if entity_description.is_supported(zone_subunit): entities.append( YamahaYncaSwitch( config_entry.entry_id, zone_subunit, entity_description diff --git a/custom_components/yamaha_ynca/translations/en.json b/custom_components/yamaha_ynca/translations/en.json index 99ff66f..6c8bcd2 100644 --- a/custom_components/yamaha_ynca/translations/en.json +++ b/custom_components/yamaha_ynca/translations/en.json @@ -150,6 +150,9 @@ }, "threedcinema": { "name": "CINEMA DSP 3D Mode" + }, + "hdmiout": { + "name": "HDMI Out" } }, "select": { diff --git a/requirements.txt b/requirements.txt index af6954a..1dea0b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -ynca==5.15.0 \ No newline at end of file +# Also update manifest.json! +ynca==5.16.0 \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index e556d1a..887eb34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -96,6 +96,8 @@ def create_mock_zone(spec=None): zone.initvollvl = None zone.initvolmode = None zone.inp = None + zone.lipsynchdmiout1offset = None + zone.lipsynchdmiout2offset = None zone.maxvol = None zone.mute = None zone.puredirmode = None diff --git a/tests/test_select.py b/tests/test_select.py index c582b42..b1a6b24 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -42,7 +42,8 @@ async def test_async_setup_entry( mock_zone_main: ZoneBase, ): mock_ynca.main = mock_zone_main - mock_ynca.main.hdmiout = ynca.HdmiOut.OFF + mock_ynca.main.hdmiout = ynca.HdmiOut.OUT1_PLUS_2 + mock_ynca.main.lipsynchdmiout2offset = 123 mock_ynca.main.sleep = ynca.Sleep.THIRTY_MIN mock_ynca.main.initvollvl = ynca.InitVolLvl.MUTE mock_ynca.main.twochdecoder = ynca.TwoChDecoder.DolbyPl2Music diff --git a/tests/test_switch.py b/tests/test_switch.py index 8a99a6f..a8b67be 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -41,6 +41,8 @@ async def test_async_setup_entry( mock_ynca.main.enhancer = ynca.Enhancer.OFF mock_ynca.main.threedcinema = ynca.ThreeDeeCinema.AUTO mock_ynca.main.puredirmode = ynca.PureDirMode.OFF + mock_ynca.main.hdmiout = ynca.HdmiOut.OUT + mock_ynca.main.lipsynchdmiout2offset = None mock_ynca.sys.hdmiout1 = ynca.HdmiOutOnOff.OFF mock_ynca.sys.hdmiout2 = ynca.HdmiOutOnOff.ON @@ -62,7 +64,7 @@ async def test_async_setup_entry( add_entities_mock.assert_called_once() entities = add_entities_mock.call_args.args[0] - assert len(entities) == 6 + assert len(entities) == 7 async def test_switch_entity_fields(mock_zone): From af4de4da0cbe8ed0377a12275fd1d05af4c3c295 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Tue, 23 Jul 2024 23:02:57 +0200 Subject: [PATCH 04/16] Bump min HA to 2024.7.0 (#302) * Bump requirements_dev.txt * Fix test * Bump min HA version for HACS * Configure Python 3.12 for Github action * Remove obsolete debug logging causing typing issues * Fix typing issues --- .github/workflows/validations.yaml | 2 +- custom_components/yamaha_ynca/__init__.py | 8 ----- custom_components/yamaha_ynca/config_flow.py | 35 +++++++++----------- hacs.json | 2 +- requirements_dev.txt | 4 +-- tests/test_init.py | 1 + 6 files changed, 21 insertions(+), 31 deletions(-) diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index 0ac9c65..8288b76 100644 --- a/.github/workflows/validations.yaml +++ b/.github/workflows/validations.yaml @@ -14,7 +14,7 @@ jobs: runs-on: "ubuntu-latest" strategy: matrix: - python-version: ["3.11"] + python-version: ["3.12"] steps: - uses: "actions/checkout@v4" - name: Set up Python ${{ matrix.python-version }} diff --git a/custom_components/yamaha_ynca/__init__.py b/custom_components/yamaha_ynca/__init__.py index f0abda0..5629752 100644 --- a/custom_components/yamaha_ynca/__init__.py +++ b/custom_components/yamaha_ynca/__init__.py @@ -125,14 +125,6 @@ async def async_handle_send_raw_ynca(hass: HomeAssistant, call: ServiceCall): async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Yamaha (YNCA) integration.""" - # Retrieval of version information has blocking IO so run in executor - def print_ynca_package_info(): - LOGGER.debug( - "ynca package info, version %s, location %s", version("ynca"), ynca.__file__ - ) - - await hass.async_add_executor_job(print_ynca_package_info) - async def async_handle_send_raw_ynca_local(call: ServiceCall): await async_handle_send_raw_ynca(hass, call) diff --git a/custom_components/yamaha_ynca/config_flow.py b/custom_components/yamaha_ynca/config_flow.py index c95d87a..1d5b230 100644 --- a/custom_components/yamaha_ynca/config_flow.py +++ b/custom_components/yamaha_ynca/config_flow.py @@ -1,20 +1,19 @@ """Config flow for Yamaha (YNCA) integration.""" + from __future__ import annotations from typing import Any, Dict import voluptuous as vol # type: ignore +import ynca -from homeassistant import config_entries -from homeassistant.core import HomeAssistant -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult - +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant, callback from .const import ( - CONF_SERIAL_URL, CONF_HOST, CONF_PORT, + CONF_SERIAL_URL, DATA_MODELNAME, DATA_ZONES, DOMAIN, @@ -22,9 +21,6 @@ ) from .options_flow import OptionsFlowHandler -import ynca - - STEP_ID_SERIAL = "serial" STEP_ID_NETWORK = "network" STEP_ID_ADVANCED = "advanced" @@ -69,15 +65,14 @@ def validate_connection(serial_url): return result -class YamahaYncaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class YamahaYncaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Yamaha (YNCA).""" # When updating also update the one used in `setup_integration` for tests VERSION = 7 MINOR_VERSION = 5 - reauth_entry: config_entries.ConfigEntry | None = None - + reauth_entry: ConfigEntry | None = None @staticmethod @callback @@ -86,7 +81,7 @@ def async_get_options_flow(config_entry): async def async_step_user( self, user_input: Dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: """Handle the initial step.""" return self.async_show_menu( step_id="user", @@ -98,7 +93,7 @@ async def async_try_connect( step_id: str, data_schema: vol.Schema, user_input: Dict[str, Any], - ) -> FlowResult: + ) -> ConfigFlowResult: errors = {} try: @@ -117,9 +112,11 @@ async def async_try_connect( data[DATA_ZONES] = check_result.zones if self.reauth_entry: - self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + self.hass.config_entries.async_update_entry( + self.reauth_entry, data=data + ) await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=check_result.modelname, data=data) @@ -131,7 +128,7 @@ async def async_try_connect( async def async_step_serial( self, user_input: Dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: if user_input is None: return self.async_show_form( step_id=STEP_ID_SERIAL, data_schema=get_serial_url_schema({}) @@ -143,7 +140,7 @@ async def async_step_serial( async def async_step_network( self, user_input: Dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: if user_input is None: return self.async_show_form( step_id=STEP_ID_NETWORK, data_schema=get_network_schema({}) @@ -158,7 +155,7 @@ async def async_step_network( async def async_step_advanced( self, user_input: Dict[str, Any] | None = None - ) -> FlowResult: + ) -> ConfigFlowResult: if user_input is None: return self.async_show_form( step_id=STEP_ID_ADVANCED, data_schema=get_serial_url_schema({}) diff --git a/hacs.json b/hacs.json index 69ef24f..c6ff93c 100644 --- a/hacs.json +++ b/hacs.json @@ -1,5 +1,5 @@ { "name": "Yamaha (YNCA)", "render_readme": true, - "homeassistant": "2024.1.0" + "homeassistant": "2024.7.0" } \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index 95f341f..12fb191 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -2,8 +2,8 @@ mypy>=1.4.0 -homeassistant-stubs==2024.1.3 -pytest-homeassistant-custom-component==0.13.89 +homeassistant-stubs==2024.7.0 +pytest-homeassistant-custom-component==0.13.144 # Not entirely clear why it is needed as not a requirement for yamaha_ynca # but the tests fail because the HA http component can not be setup because of missing lib. diff --git a/tests/test_init.py b/tests/test_init.py index 7f7f090..c819660 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -209,6 +209,7 @@ async def test_update_configentry(hass, mock_ynca, mock_zone_main, mock_zone_zon # no zones, will be added by function under test: yamaha_ynca.const.DATA_ZONES: zones, }, ) + entry.add_to_hass(hass) await yamaha_ynca.update_configentry(hass, entry, mock_ynca) From 0cc70f750ee32abd09bf43ec7cf4e0178074991d Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 10:47:30 +0200 Subject: [PATCH 05/16] Fix warning about manual setting of version on configentry in migrations (#303) * Use version arguments in update_entity iso manully setting version which is deprecated * Remove unused import --- custom_components/yamaha_ynca/__init__.py | 1 - custom_components/yamaha_ynca/migrations.py | 61 ++++++++++++--------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/custom_components/yamaha_ynca/__init__.py b/custom_components/yamaha_ynca/__init__.py index 5629752..561d0b9 100644 --- a/custom_components/yamaha_ynca/__init__.py +++ b/custom_components/yamaha_ynca/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -from importlib.metadata import version import re from typing import List diff --git a/custom_components/yamaha_ynca/migrations.py b/custom_components/yamaha_ynca/migrations.py index 08c3f8d..2bb3884 100644 --- a/custom_components/yamaha_ynca/migrations.py +++ b/custom_components/yamaha_ynca/migrations.py @@ -9,14 +9,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry, entity_registry +from .const import CONF_HIDDEN_SOUND_MODES, DOMAIN, LOGGER from .helpers import receiver_requires_audio_input_workaround -from .const import ( - CONF_HIDDEN_SOUND_MODES, - DOMAIN, - LOGGER, -) - async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): """Migrate old entry.""" @@ -50,7 +45,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): if config_entry.minor_version == 3: migrate_v7_3_to_v7_4(hass, config_entry) if config_entry.minor_version == 4: - migrate_v7_4_to_v7_5(hass, config_entry) + migrate_v7_4_to_v7_5(hass, config_entry) # When adding new migrations do _not_ forget # to increase the VERSION of the YamahaYncaConfigFlow @@ -66,6 +61,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): return True + def migrate_v7_4_to_v7_5(hass: HomeAssistant, config_entry: ConfigEntry): options = dict(config_entry.options) # Convert to dict to be able to use .get @@ -81,8 +77,10 @@ def migrate_v7_4_to_v7_5(hass: HomeAssistant, config_entry: ConfigEntry): ) options[zone_id]["hidden_inputs"].append("TV") - config_entry.minor_version = 5 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=5 + ) + def migrate_v7_3_to_v7_4(hass: HomeAssistant, config_entry: ConfigEntry): options = dict(config_entry.options) # Convert to dict to be able to use .get @@ -99,8 +97,9 @@ def migrate_v7_3_to_v7_4(hass: HomeAssistant, config_entry: ConfigEntry): ) options[zone_id]["hidden_inputs"].append("AUDIO5") - config_entry.minor_version = 4 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=4 + ) def migrate_v7_2_to_v7_3(hass: HomeAssistant, config_entry: ConfigEntry): @@ -128,8 +127,9 @@ def migrate_v7_2_to_v7_3(hass: HomeAssistant, config_entry: ConfigEntry): "dts_neo_6_music", ] - config_entry.minor_version = 3 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=3 + ) def migrate_v7_1_to_v7_2(hass: HomeAssistant, config_entry: ConfigEntry): @@ -147,8 +147,9 @@ def migrate_v7_1_to_v7_2(hass: HomeAssistant, config_entry: ConfigEntry): ) options[zone_id]["hidden_inputs"].append("AUDIO") - config_entry.minor_version = 2 - hass.config_entries.async_update_entry(config_entry, options=options) + hass.config_entries.async_update_entry( + config_entry, options=options, minor_version=2 + ) def migrate_v6_to_v7(hass: HomeAssistant, config_entry: ConfigEntry): @@ -163,8 +164,9 @@ def migrate_v6_to_v7(hass: HomeAssistant, config_entry: ConfigEntry): if device_entry := registry.async_get_device(identifiers=old_identifiers): registry.async_update_device(device_entry.id, new_identifiers=new_identifiers) - config_entry.version = 7 - hass.config_entries.async_update_entry(config_entry, data=config_entry.data) + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data, version=7, minor_version=1 + ) def migrate_v5_to_v6(hass: HomeAssistant, config_entry: ConfigEntry): @@ -186,9 +188,8 @@ def migrate_v5_to_v6(hass: HomeAssistant, config_entry: ConfigEntry): new_data = {**config_entry.data} new_data["modelname"] = config_entry.title - config_entry.version = 6 hass.config_entries.async_update_entry( - config_entry, data=new_data, options=new_options + config_entry, data=new_data, options=new_options, version=6, minor_version=1 ) @@ -215,8 +216,9 @@ def serial_url_from_user_input(user_input: str) -> str: new = {**config_entry.data} new["serial_url"] = serial_url_from_user_input(config_entry.data["serial_url"]) - config_entry.version = 5 - hass.config_entries.async_update_entry(config_entry, data=new) + hass.config_entries.async_update_entry( + config_entry, data=new, version=5, minor_version=1 + ) def migrate_v3_to_v4(hass: HomeAssistant, config_entry: ConfigEntry): @@ -233,9 +235,12 @@ def migrate_v3_to_v4(hass: HomeAssistant, config_entry: ConfigEntry): pass options[CONF_HIDDEN_SOUND_MODES] = new_hidden_soundmodes - config_entry.version = 4 hass.config_entries.async_update_entry( - config_entry, data=config_entry.data, options=options + config_entry, + data=config_entry.data, + options=options, + version=4, + minor_version=1, ) @@ -251,8 +256,9 @@ def migrate_v2_to_v3(hass: HomeAssistant, config_entry: ConfigEntry): if entity.domain == Platform.SCENE: registry.async_remove(entity.entity_id) - config_entry.version = 3 - hass.config_entries.async_update_entry(config_entry, data=config_entry.data) + hass.config_entries.async_update_entry( + config_entry, data=config_entry.data, version=3, minor_version=1 + ) def migrate_v1_to_v2(hass: HomeAssistant, config_entry: ConfigEntry): @@ -270,5 +276,6 @@ def migrate_v1_to_v2(hass: HomeAssistant, config_entry: ConfigEntry): new = {**config_entry.data} new["serial_url"] = new.pop("serial_port") - config_entry.version = 2 - hass.config_entries.async_update_entry(config_entry, data=new) + hass.config_entries.async_update_entry( + config_entry, data=new, version=2, minor_version=1 + ) From c775b62c9a80bc07b5034eb684de180131ee3801 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 11:40:27 +0200 Subject: [PATCH 06/16] Dont hide remote entity by default (#304) * Don't make remote entities disabled by default * Use entity_registry_enabled_by_default fixtures iso manual version --- custom_components/yamaha_ynca/remote.py | 5 ++-- tests/conftest.py | 31 ++++++++----------------- tests/test_number.py | 12 ++++++---- 3 files changed, 19 insertions(+), 29 deletions(-) diff --git a/custom_components/yamaha_ynca/remote.py b/custom_components/yamaha_ynca/remote.py index 5147049..229b20d 100644 --- a/custom_components/yamaha_ynca/remote.py +++ b/custom_components/yamaha_ynca/remote.py @@ -116,7 +116,6 @@ class YamahaYncaZoneRemote(RemoteEntity): r"^(?P([0-9A-F]{2}){1,2}?)[^0-9A-F]?(?P([0-9A-F]{2}){1,2})$" ) _attr_has_entity_name = True - _attr_entity_registry_enabled_default = False _unrecorded_attributes = frozenset({ATTR_COMMANDS}) def __init__( @@ -177,6 +176,6 @@ def send_command(self, command: Iterable[str], **kwargs): for cmd in command: # Use raw remotecode from mapping otherwise assume user provided raw code code = self._zone_codes.get(cmd, cmd) - code = self._format_remotecode(code) + formatted_code = self._format_remotecode(code) - self._api.sys.remotecode(code) # type: ignore + self._api.sys.remotecode(formatted_code) # type: ignore diff --git a/tests/conftest.py b/tests/conftest.py index 887eb34..f797d92 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable, NamedTuple, Type +from typing import Callable, Generator, NamedTuple, Type from unittest.mock import DEFAULT, Mock, create_autospec, patch import pytest @@ -49,6 +49,15 @@ def auto_enable_custom_integrations(enable_custom_integrations): yield +# Copied from HA tests/components/conftest.py +@pytest.fixture +def entity_registry_enabled_by_default() -> Generator[None]: + """Test fixture that ensures all entities are enabled in the registry.""" + with patch( + "homeassistant.helpers.entity.Entity.entity_registry_enabled_default", + return_value=True, + ): + yield @pytest.fixture def mock_zone(): @@ -199,7 +208,6 @@ async def setup_integration( mock_ynca: ynca.YncaApi, skip_setup=False, serial_url="SerialUrl", - enable_all_entities=False, ): zones = [] if mock_ynca.main: @@ -214,25 +222,6 @@ async def setup_integration( entry = create_mock_config_entry(modelname=mock_ynca.sys.modelname, zones=zones, serial_url=serial_url) entry.add_to_hass(hass) - if enable_all_entities: - # Pre-create registry entries for default disabled ones - er = entity_registry.async_get(hass) - for disabled_entity in [ - DisabledEntity(Platform.NUMBER, "vol"), - DisabledEntity(Platform.NUMBER, "spbass"), - DisabledEntity(Platform.NUMBER, "sptreble"), - DisabledEntity(Platform.NUMBER, "hpbass"), - DisabledEntity(Platform.NUMBER, "hptreble"), - ]: - er.async_get_or_create( - disabled_entity.platform, - yamaha_ynca.DOMAIN, - f"entry_id_MAIN_{disabled_entity.key}", - suggested_object_id=disabled_entity.key, - disabled_by=None, - config_entry=entry, - ) - on_disconnect = None if not skip_setup: diff --git a/tests/test_number.py b/tests/test_number.py index ad2aa31..d59fdcd 100644 --- a/tests/test_number.py +++ b/tests/test_number.py @@ -2,6 +2,7 @@ from unittest.mock import ANY, Mock, call, patch +import pytest import ynca import custom_components.yamaha_ynca as yamaha_ynca @@ -50,8 +51,8 @@ async def test_async_setup_entry( mock_ynca.main.maxvol = 0 mock_ynca.main.spbass = -1 mock_ynca.main.sptreble = 1 - mock_ynca.main.hpbass = None - mock_ynca.main.hptreble = None + mock_ynca.main.hpbass = 2 + mock_ynca.main.hptreble = 3 mock_ynca.main.initvollvl = 1.0 integration = await setup_integration(hass, mock_ynca) @@ -78,7 +79,7 @@ async def test_async_setup_entry( add_entities_mock.assert_called_once() entities = add_entities_mock.call_args.args[0] - assert len(entities) == 4 + assert len(entities) == 6 async def test_number_entity(hass, mock_ynca, mock_zone_main): @@ -104,13 +105,14 @@ async def test_number_entity(hass, mock_ynca, mock_zone_main): assert mock_zone_main.maxvol == 10 +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_number_entity_volume(hass, mock_ynca, mock_zone_main): - entity_under_test = "number.vol" + entity_under_test = 'number.modelname_main_volume_db' mock_zone_main.vol = -5 mock_zone_main.pwr = ynca.Pwr.ON mock_ynca.main = mock_zone_main - await setup_integration(hass, mock_ynca, enable_all_entities=True) + await setup_integration(hass, mock_ynca) # Initial value volume = hass.states.get(entity_under_test) From c3861e62d448b9c70bb4e9cdfc5a2f58880b1b02 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 12:08:37 +0200 Subject: [PATCH 07/16] Store runtime data inside config entry (#305) * Introduce YamahaYncaConfigEntry en remove hass.data usage from init * Update platforms to use runtime data iso hass.data * Remove hass.data usage from optionsflow * Fix typing info * Enable NewGenericSyntax flag for mypy so it understands runtimedata type * Update custom_components/yamaha_ynca/media_player.py Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Reduce nesting --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .github/workflows/validations.yaml | 2 +- coverage.sh | 2 +- custom_components/yamaha_ynca/__init__.py | 31 ++++---- custom_components/yamaha_ynca/button.py | 13 ++-- custom_components/yamaha_ynca/diagnostics.py | 9 ++- custom_components/yamaha_ynca/media_player.py | 72 ++++++++++--------- custom_components/yamaha_ynca/number.py | 17 +++-- custom_components/yamaha_ynca/options_flow.py | 33 ++++----- custom_components/yamaha_ynca/remote.py | 35 +++++---- custom_components/yamaha_ynca/select.py | 17 +++-- custom_components/yamaha_ynca/switch.py | 51 ++++++++----- tests/conftest.py | 7 +- tests/test_init.py | 7 +- tests/test_options_flow.py | 2 +- 14 files changed, 165 insertions(+), 133 deletions(-) diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index 8288b76..e2aa357 100644 --- a/.github/workflows/validations.yaml +++ b/.github/workflows/validations.yaml @@ -32,7 +32,7 @@ jobs: pytest - name: Check typing run: | - mypy custom_components --check-untyped-defs + mypy custom_components --check-untyped-defs --enable-incomplete-feature=NewGenericSyntax - name: Hassfest validation uses: "home-assistant/actions/hassfest@master" - name: HACS validation diff --git a/coverage.sh b/coverage.sh index 549bcb6..e64e6a0 100755 --- a/coverage.sh +++ b/coverage.sh @@ -1,3 +1,3 @@ #!/bin/sh pytest --cov=custom_components/yamaha_ynca tests/ --cov-report term-missing --cov-report html -mypy custom_components --check-untyped-defs +mypy custom_components --check-untyped-defs --enable-incomplete-feature=NewGenericSyntax diff --git a/custom_components/yamaha_ynca/__init__.py b/custom_components/yamaha_ynca/__init__.py index 561d0b9..c3aeabf 100644 --- a/custom_components/yamaha_ynca/__init__.py +++ b/custom_components/yamaha_ynca/__init__.py @@ -109,13 +109,15 @@ async def async_update_options(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_handle_send_raw_ynca(hass: HomeAssistant, call: ServiceCall): - config_entry_ids = await async_extract_config_entry_ids(hass, call) - for config_entry_id in config_entry_ids: - if domain_entry_info := hass.data[DOMAIN].get(config_entry_id, None): - for line in call.data.get("raw_data").splitlines(): - line = line.strip() - if line.startswith("@"): - domain_entry_info.api.send_raw(line) + for config_entry_id in await async_extract_config_entry_ids(hass, call): + if config_entry := hass.config_entries.async_get_entry(config_entry_id): + # Check if configentry is ours, could be others when targeting areas for example + if (config_entry.domain == DOMAIN) and (domain_entry_info := config_entry.runtime_data): + # Handle actual call + for line in call.data.get("raw_data").splitlines(): + line = line.strip() + if line.startswith("@"): + domain_entry_info.api.send_raw(line) CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @@ -134,7 +136,10 @@ async def async_handle_send_raw_ynca_local(call: ServiceCall): return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +type YamahaYncaConfigEntry = ConfigEntry[DomainEntryData] + + +async def async_setup_entry(hass: HomeAssistant, entry: YamahaYncaConfigEntry) -> bool: """Set up Yamaha (YNCA) from a config entry.""" def initialize_ynca(ynca_receiver: ynca.YncaApi): @@ -189,10 +194,11 @@ def on_disconnect(): # This makes it work with standard code ynca_receiver.sys.inpnameaudio = "AUDIO" # type: ignore - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DomainEntryData( + entry.runtime_data = DomainEntryData( api=ynca_receiver, initialization_events=ynca_receiver.get_communication_log_items(), ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) @@ -200,17 +206,14 @@ def on_disconnect(): return initialized -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: YamahaYncaConfigEntry) -> bool: """Unload a config entry.""" def close_ynca(ynca_receiver: ynca.YncaApi): ynca_receiver.close() if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - domain_entry_info = hass.data[DOMAIN].pop(entry.entry_id) + domain_entry_info = entry.runtime_data await hass.async_add_executor_job(close_ynca, domain_entry_info.api) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok diff --git a/custom_components/yamaha_ynca/button.py b/custom_components/yamaha_ynca/button.py index 1904ced..e76d633 100644 --- a/custom_components/yamaha_ynca/button.py +++ b/custom_components/yamaha_ynca/button.py @@ -3,8 +3,11 @@ from typing import Any from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import YamahaYncaConfigEntry from .const import ( CONF_NUMBER_OF_SCENES, DOMAIN, @@ -12,28 +15,22 @@ NUMBER_OF_SCENES_AUTODETECT, ZONE_ATTRIBUTE_NAMES, ) -from .helpers import DomainEntryData -async def async_setup_entry(hass, config_entry, async_add_entities): - - domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] - +async def async_setup_entry(hass: HomeAssistant, config_entry: YamahaYncaConfigEntry, async_add_entities: AddEntitiesCallback): + domain_entry_data = config_entry.runtime_data entities = [] for zone_attr_name in ZONE_ATTRIBUTE_NAMES: if zone_subunit := getattr(domain_entry_data.api, zone_attr_name): number_of_scenes = config_entry.options.get(zone_subunit.id, {}).get( CONF_NUMBER_OF_SCENES, NUMBER_OF_SCENES_AUTODETECT ) - if number_of_scenes == NUMBER_OF_SCENES_AUTODETECT: number_of_scenes = 0 for scene_id in range(1, MAX_NUMBER_OF_SCENES + 1): if getattr(zone_subunit, f"scene{scene_id}name"): number_of_scenes += 1 - number_of_scenes = min(MAX_NUMBER_OF_SCENES, number_of_scenes) - for scene_id in range(1, number_of_scenes + 1): entities.append( YamahaYncaSceneButton(config_entry.entry_id, zone_subunit, scene_id) diff --git a/custom_components/yamaha_ynca/diagnostics.py b/custom_components/yamaha_ynca/diagnostics.py index 37f0424..5874b74 100644 --- a/custom_components/yamaha_ynca/diagnostics.py +++ b/custom_components/yamaha_ynca/diagnostics.py @@ -1,26 +1,25 @@ """Diagnostics support for Yamaha (YNCA).""" + from __future__ import annotations from typing import Any import ynca -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .helpers import DomainEntryData +from . import YamahaYncaConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: YamahaYncaConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" data = {} data["config_entry"] = entry.as_dict() # Add data from the device itself - domain_entry_data: DomainEntryData = hass.data[DOMAIN].get(entry.entry_id, None) + domain_entry_data = entry.runtime_data if domain_entry_data: api: ynca.YncaApi = domain_entry_data.api if api.sys: diff --git a/custom_components/yamaha_ynca/media_player.py b/custom_components/yamaha_ynca/media_player.py index c394e41..3ecd24a 100644 --- a/custom_components/yamaha_ynca/media_player.py +++ b/custom_components/yamaha_ynca/media_player.py @@ -18,7 +18,7 @@ MediaType, RepeatMode, ) -from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, @@ -26,8 +26,9 @@ entity_platform, ) from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import build_devicename +from . import YamahaYncaConfigEntry, build_devicename from .const import ( ATTR_PRESET_ID, CONF_HIDDEN_INPUTS, @@ -39,7 +40,7 @@ ZONE_MAX_VOLUME, ZONE_MIN_VOLUME, ) -from .helpers import DomainEntryData, scale +from .helpers import scale from .input_helpers import InputHelper if TYPE_CHECKING: # pragma: no cover @@ -51,8 +52,12 @@ SUPPORTED_MEDIA_ID_TYPES = ["dabpreset", "fmpreset", "preset"] -async def async_setup_entry(hass, config_entry: ConfigEntry, async_add_entities): - domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: YamahaYncaConfigEntry, + async_add_entities: AddEntitiesCallback, +): + domain_entry_data = config_entry.runtime_data platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( @@ -510,7 +515,6 @@ async def async_browse_media( media_content_type, ) - if media_content_id is None or media_content_id == "presets": return self.build_media_root_item() @@ -520,8 +524,9 @@ async def async_browse_media( subunit_attribute_name = parts[0] media_content_id_type = parts[1] - return self.build_presetlist_media_item(subunit_attribute_name, media_content_id_type) - + return self.build_presetlist_media_item( + subunit_attribute_name, media_content_id_type + ) raise HomeAssistantError( f"Media content id could not be resolved: {media_content_id}" @@ -537,14 +542,18 @@ def build_media_root_item(self): if subunit := InputHelper.get_subunit_for_input(self._ynca, input): if hasattr(subunit, "preset"): children.append( - self.directory_browse_media_item(name, f"{subunit.id.value.lower()}:presets", []) + self.directory_browse_media_item( + name, f"{subunit.id.value.lower()}:presets", [] + ) ) # Presets for DAB Tuner, it has 2 preset lists and uses different attribute names, so add manually if self._ynca.dab and ynca.Input.TUNER.value not in self._hidden_inputs: children.extend( [ - self.directory_browse_media_item("TUNER (DAB)", "dab:dabpresets", []), + self.directory_browse_media_item( + "TUNER (DAB)", "dab:dabpresets", [] + ), self.directory_browse_media_item("TUNER (FM)", "dab:fmpresets", []), ] ) @@ -559,8 +568,10 @@ def build_media_root_item(self): children=children, children_media_class=MediaClass.DIRECTORY, ) - - def build_presetlist_media_item(self, subunit_attribute_name, media_content_id_type): + + def build_presetlist_media_item( + self, subunit_attribute_name, media_content_id_type + ): if media_content_id_type == "dabpresets": name = "TUNER (DAB)" elif media_content_id_type == "fmpresets": @@ -571,7 +582,9 @@ def build_presetlist_media_item(self, subunit_attribute_name, media_content_id_t source_mapping = InputHelper.get_source_mapping(self._ynca) name = source_mapping.get(input, source_mapping[input]) - stripped_media_content_id_type = media_content_id_type[:-1] # Strips the 's' of xyz_presets + stripped_media_content_id_type = media_content_id_type[ + :-1 + ] # Strips the 's' of xyz_presets preset_items = [ BrowseMedia( media_class=MediaClass.MUSIC, @@ -585,13 +598,9 @@ def build_presetlist_media_item(self, subunit_attribute_name, media_content_id_t ] return self.directory_browse_media_item( - name, - f"{subunit_attribute_name}:{media_content_id_type}", - preset_items + name, f"{subunit_attribute_name}:{media_content_id_type}", preset_items ) - - def directory_browse_media_item(self, name, media_content_id, presets): return BrowseMedia( media_class=MediaClass.DIRECTORY, @@ -603,7 +612,7 @@ def directory_browse_media_item(self, name, media_content_id, presets): children=presets, children_media_class=MediaClass.MUSIC, ) - + async def async_play_media( self, media_type: str, @@ -624,15 +633,15 @@ async def async_play_media( parts = media_id.split(":") if len(parts) != 3: - raise HomeAssistantError( - f"Malformed media id: {media_id}" - ) + raise HomeAssistantError(f"Malformed media id: {media_id}") media_id_subunit = parts[0] media_id_command = parts[1] media_id_preset_id = parts[2] - self.validate_media_id(media_id, media_id_subunit, media_id_command, media_id_preset_id) + self.validate_media_id( + media_id, media_id_subunit, media_id_command, media_id_preset_id + ) # Apply media_id to receiver if self._zone.pwr is ynca.Pwr.STANDBY: @@ -651,26 +660,21 @@ async def async_play_media( setattr(subunit, media_id_command, int(media_id_preset_id)) - - def validate_media_id(self, media_id, media_id_subunit, media_id_command, media_id_preset_id): + def validate_media_id( + self, media_id, media_id_subunit, media_id_command, media_id_preset_id + ): if not hasattr(self._ynca, media_id_subunit): - raise HomeAssistantError( - f"Malformed media id: {media_id}" - ) + raise HomeAssistantError(f"Malformed media id: {media_id}") if media_id_command not in ["preset", "fmpreset", "dabpreset"]: - raise HomeAssistantError( - f"Malformed media id: {media_id}" - ) + raise HomeAssistantError(f"Malformed media id: {media_id}") try: preset_id = int(media_id_preset_id) if preset_id < 1 or preset_id > 40: raise ValueError except ValueError: - raise HomeAssistantError( - f"Malformed preset or out of range: {media_id}" - ) + raise HomeAssistantError(f"Malformed preset or out of range: {media_id}") from None def store_preset(self, preset_id: int) -> None: if subunit := InputHelper.get_subunit_for_input(self._ynca, self._zone.inp): diff --git a/custom_components/yamaha_ynca/number.py b/custom_components/yamaha_ynca/number.py index 032c8e8..f799569 100644 --- a/custom_components/yamaha_ynca/number.py +++ b/custom_components/yamaha_ynca/number.py @@ -1,6 +1,6 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass from typing import Callable, List import ynca @@ -11,10 +11,13 @@ NumberEntityDescription, ) from homeassistant.const import SIGNAL_STRENGTH_DECIBELS +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ZONE_ATTRIBUTE_NAMES, ZONE_MAX_VOLUME, ZONE_MIN_VOLUME -from .helpers import DomainEntryData, YamahaYncaSettingEntity +from . import YamahaYncaConfigEntry +from .const import ZONE_ATTRIBUTE_NAMES, ZONE_MAX_VOLUME, ZONE_MIN_VOLUME +from .helpers import YamahaYncaSettingEntity def volume_native_max_value_fn(associated_zone: ynca.subunits.zone.ZoneBase) -> float: @@ -116,8 +119,12 @@ class YncaNumberEntityDescription(NumberEntityDescription): ) -async def async_setup_entry(hass, config_entry, async_add_entities): - domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: YamahaYncaConfigEntry, + async_add_entities: AddEntitiesCallback, +): + domain_entry_data = config_entry.runtime_data entities = [] for zone_attr_name in ZONE_ATTRIBUTE_NAMES: diff --git a/custom_components/yamaha_ynca/options_flow.py b/custom_components/yamaha_ynca/options_flow.py index cd2d92d..72ded0c 100644 --- a/custom_components/yamaha_ynca/options_flow.py +++ b/custom_components/yamaha_ynca/options_flow.py @@ -1,7 +1,6 @@ """Options flow for Yamaha (YNCA) integration.""" -from __future__ import annotations -from typing import Any, Dict +from __future__ import annotations import voluptuous as vol # type: ignore import ynca @@ -9,8 +8,6 @@ from custom_components.yamaha_ynca.input_helpers import InputHelper from homeassistant import config_entries import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import selector -from homeassistant.util import slugify from .const import ( CONF_HIDDEN_INPUTS, @@ -25,8 +22,8 @@ LOGGER, MAX_NUMBER_OF_SCENES, NUMBER_OF_SCENES_AUTODETECT, - TWOCHDECODER_STRINGS, SURROUNDDECODEROPTIONS_PLIIX_MAPPING, + TWOCHDECODER_STRINGS, ) STEP_ID_INIT = "init" @@ -55,6 +52,7 @@ STEP_ID_ZONE4, ] + def get_next_step_id(flow: OptionsFlowHandler, current_step: str) -> str: index = STEP_SEQUENCE.index(current_step) next_step = STEP_SEQUENCE[index + 1] @@ -77,13 +75,8 @@ async def do_next_step(self, current_step_id: str): async def async_step_init(self, user_input=None): """Basic sanity checks before configuring options.""" - if ( - DOMAIN in self.hass.data - and self.config_entry.entry_id in self.hass.data[DOMAIN] - ): - self.api: ynca.YncaApi = self.hass.data[DOMAIN][ - self.config_entry.entry_id - ].api + if runtime_data := self.config_entry.runtime_data: + self.api = runtime_data.api return await self.async_step_general() return await self.async_step_no_connection() @@ -123,7 +116,9 @@ async def async_step_general(self, user_input=None): self.options[CONF_HIDDEN_SOUND_MODES] = hidden_sound_modes if CONF_SELECTED_SURROUND_DECODERS in user_input: - self.options[CONF_SELECTED_SURROUND_DECODERS] = user_input[CONF_SELECTED_SURROUND_DECODERS] + self.options[CONF_SELECTED_SURROUND_DECODERS] = user_input[ + CONF_SELECTED_SURROUND_DECODERS + ] return await self.do_next_step(STEP_ID_GENERAL) @@ -154,10 +149,16 @@ async def async_step_general(self, user_input=None): stored_selected_surround_decoders_ids = self.options.get( CONF_SELECTED_SURROUND_DECODERS, [] ) - all_surround_decoders = dict(sorted(TWOCHDECODER_STRINGS.items(), key=lambda item: item[1].lower())) + all_surround_decoders = dict( + sorted( + TWOCHDECODER_STRINGS.items(), key=lambda item: item[1].lower() + ) + ) if not stored_selected_surround_decoders_ids: - stored_selected_surround_decoders_ids = list(all_surround_decoders.keys()) + stored_selected_surround_decoders_ids = list( + all_surround_decoders.keys() + ) # Could technically use translation for this in Options flow, but only by using SelectorSelect # but the multiselect UI it creates is a hassle to use and since there are no translations yet @@ -167,7 +168,7 @@ async def async_step_general(self, user_input=None): CONF_SELECTED_SURROUND_DECODERS, default=stored_selected_surround_decoders_ids, ) - ] = cv.multi_select(all_surround_decoders) + ] = cv.multi_select(all_surround_decoders) return self.async_show_form( step_id=STEP_ID_GENERAL, diff --git a/custom_components/yamaha_ynca/remote.py b/custom_components/yamaha_ynca/remote.py index 229b20d..e77c085 100644 --- a/custom_components/yamaha_ynca/remote.py +++ b/custom_components/yamaha_ynca/remote.py @@ -1,18 +1,17 @@ from __future__ import annotations -import re +import re from typing import TYPE_CHECKING, Any, Dict, Iterable +import ynca + from homeassistant.components.remote import RemoteEntity +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_COMMANDS, - DOMAIN, - ZONE_ATTRIBUTE_NAMES, -) -from .helpers import DomainEntryData -import ynca +from . import YamahaYncaConfigEntry +from .const import ATTR_COMMANDS, DOMAIN, ZONE_ATTRIBUTE_NAMES if TYPE_CHECKING: # pragma: no cover from ynca.subunits.zone import ZoneBase @@ -91,8 +90,12 @@ def get_zone_codes(zone_id: str) -> Dict[str, str]: return codes -async def async_setup_entry(hass, config_entry, async_add_entities): - domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: YamahaYncaConfigEntry, + async_add_entities: AddEntitiesCallback, +): + domain_entry_data = config_entry.runtime_data entities = [] for zone_attr_name in ZONE_ATTRIBUTE_NAMES: @@ -135,7 +138,9 @@ def __init__( identifiers={(DOMAIN, f"{receiver_unique_id}_{zone.id}")} ) - self._attr_extra_state_attributes = {ATTR_COMMANDS: list(self._zone_codes.keys())} + self._attr_extra_state_attributes = { + ATTR_COMMANDS: list(self._zone_codes.keys()) + } def _format_remotecode(self, input_code: str) -> str: """ @@ -156,9 +161,11 @@ def _format_remotecode(self, input_code: str) -> str: output_code += part # Add filler byte by inverting the first byte, research NEC ir codes for more info # Invert with 'xor 0xFF' because Python ~ operator makes it signed otherwise - output_code += int.to_bytes( - int.from_bytes(bytes.fromhex(part)) ^ 0xFF - ).hex().upper() + output_code += ( + int.to_bytes(int.from_bytes(bytes.fromhex(part)) ^ 0xFF) + .hex() + .upper() + ) else: output_code += part return output_code diff --git a/custom_components/yamaha_ynca/select.py b/custom_components/yamaha_ynca/select.py index 5c951d4..cd92c26 100644 --- a/custom_components/yamaha_ynca/select.py +++ b/custom_components/yamaha_ynca/select.py @@ -8,17 +8,19 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify +from . import YamahaYncaConfigEntry from .const import ( CONF_SELECTED_SURROUND_DECODERS, - DOMAIN, + SURROUNDDECODEROPTIONS_PLIIX_MAPPING, TWOCHDECODER_STRINGS, ZONE_ATTRIBUTE_NAMES, - SURROUNDDECODEROPTIONS_PLIIX_MAPPING, ) -from .helpers import DomainEntryData, YamahaYncaSettingEntity +from .helpers import YamahaYncaSettingEntity if TYPE_CHECKING: # pragma: no cover from ynca.subunit import SubunitBase @@ -31,9 +33,12 @@ class InitialVolumeMode(str, Enum): MUTE = "mute" -async def async_setup_entry(hass, config_entry, async_add_entities): - - domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: YamahaYncaConfigEntry, + async_add_entities: AddEntitiesCallback, +): + domain_entry_data = config_entry.runtime_data entities = [] for zone_attr_name in ZONE_ATTRIBUTE_NAMES: diff --git a/custom_components/yamaha_ynca/switch.py b/custom_components/yamaha_ynca/switch.py index 7b3851a..a8419cf 100644 --- a/custom_components/yamaha_ynca/switch.py +++ b/custom_components/yamaha_ynca/switch.py @@ -1,22 +1,24 @@ from __future__ import annotations + from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, Callable, List import ynca -from homeassistant.components.switch import ( - SwitchEntity, - SwitchEntityDescription, -) +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ZONE_ATTRIBUTE_NAMES -from .helpers import DomainEntryData, YamahaYncaSettingEntity +from . import YamahaYncaConfigEntry +from .const import ZONE_ATTRIBUTE_NAMES +from .helpers import YamahaYncaSettingEntity if TYPE_CHECKING: # pragma: no cover from ynca.subunits.zone import ZoneBase + @dataclass(frozen=True, kw_only=True) class YncaSwitchEntityDescription(SwitchEntityDescription): on: Enum | None = None @@ -79,8 +81,9 @@ def is_supported(self, zone_subunit: ZoneBase): on=ynca.HdmiOut.OUT, off=ynca.HdmiOut.OFF, # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output - # This switch handles single HDMI output, so check if HDMI2 does NOT exist and assume there is only one HDMI output - supported_check=lambda _, zone_subunit: zone_subunit.lipsynchdmiout2offset is None + # This switch handles single HDMI output, so check if HDMI2 does NOT exist and assume there is only one HDMI output + supported_check=lambda _, zone_subunit: zone_subunit.lipsynchdmiout2offset + is None, ), ] @@ -105,9 +108,13 @@ def is_supported(self, zone_subunit: ZoneBase): ), ] -async def async_setup_entry(hass, config_entry, async_add_entities): - domain_entry_data: DomainEntryData = hass.data[DOMAIN][config_entry.entry_id] +async def async_setup_entry( + hass: HomeAssistant, + config_entry: YamahaYncaConfigEntry, + async_add_entities: AddEntitiesCallback, +): + domain_entry_data = config_entry.runtime_data entities = [] for zone_attr_name in ZONE_ATTRIBUTE_NAMES: @@ -121,16 +128,24 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) # These are features on the SYS subunit, but they are tied to a zone - assert(domain_entry_data.api.sys is not None) + assert domain_entry_data.api.sys is not None for entity_description in SYS_ENTITY_DESCRIPTIONS: - assert(isinstance(entity_description.associated_zone_attr, str)) - if getattr(domain_entry_data.api.sys, entity_description.key, None) is not None: - if zone_subunit := getattr(domain_entry_data.api, entity_description.associated_zone_attr): - entities.append( - YamahaYncaSwitch( - config_entry.entry_id, domain_entry_data.api.sys, entity_description, associated_zone=zone_subunit - ) + assert isinstance(entity_description.associated_zone_attr, str) + if ( + getattr(domain_entry_data.api.sys, entity_description.key, None) is not None + ) and ( + zone_subunit := getattr( + domain_entry_data.api, entity_description.associated_zone_attr + ) + ): + entities.append( + YamahaYncaSwitch( + config_entry.entry_id, + domain_entry_data.api.sys, + entity_description, + associated_zone=zone_subunit, ) + ) async_add_entities(entities) diff --git a/tests/conftest.py b/tests/conftest.py index f797d92..34f4f66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,13 +4,12 @@ from dataclasses import dataclass from typing import Callable, Generator, NamedTuple, Type -from unittest.mock import DEFAULT, Mock, create_autospec, patch +from unittest.mock import DEFAULT, Mock, patch import pytest -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry, entity_registry +from homeassistant.helpers import device_registry from pytest_homeassistant_custom_component.common import ( # type: ignore[import] MockConfigEntry, @@ -192,7 +191,7 @@ def create_mock_config_entry(modelname=None, zones=None, serial_url=None): class Integration(NamedTuple): - entry: Type[ConfigEntry] + entry: Type[yamaha_ynca.YamahaYncaConfigEntry] on_disconnect: Callable | None mock_ynca: Type[Mock] diff --git a/tests/test_init.py b/tests/test_init.py index c819660..f06f43f 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -35,7 +35,7 @@ async def test_async_setup_entry( assert len(mock_ynca.initialize.mock_calls) == 1 assert ( - mock_ynca is hass.data.get(yamaha_ynca.DOMAIN)[integration.entry.entry_id].api + mock_ynca is integration.entry.runtime_data.api ) assert len(device_reg.devices.keys()) == 4 @@ -107,7 +107,6 @@ async def test_async_setup_entry_fails_with_connection_error(hass, mock_ynca): await hass.async_block_till_done() assert integration.entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(yamaha_ynca.DOMAIN) # Unload to avoid errors about "Lingering timer" which was started to retry setup await hass.config_entries.async_unload(integration.entry.entry_id) @@ -124,7 +123,6 @@ async def test_async_setup_entry_fails_with_connection_failed(hass, mock_ynca): await hass.async_block_till_done() assert integration.entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(yamaha_ynca.DOMAIN) # Unload to avoid errors about "Lingering timer" which was started to retry setup await hass.config_entries.async_unload(integration.entry.entry_id) @@ -145,7 +143,6 @@ async def test_async_setup_entry_fails_with_initialization_failed_error( await hass.async_block_till_done() assert integration.entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(yamaha_ynca.DOMAIN) # Unload to avoid errors about "Lingering timer" which was started to retry setup await hass.config_entries.async_unload(integration.entry.entry_id) @@ -162,7 +159,6 @@ async def test_async_setup_entry_fails_unknown_reason(hass, mock_ynca): await hass.async_block_till_done() assert integration.entry.state is ConfigEntryState.SETUP_ERROR - assert not hass.data.get(yamaha_ynca.DOMAIN) async def test_async_unload_entry(hass, mock_ynca, mock_zone_main): @@ -175,7 +171,6 @@ async def test_async_unload_entry(hass, mock_ynca, mock_zone_main): mock_ynca.close.assert_called_once() assert integration.entry.state is ConfigEntryState.NOT_LOADED - assert yamaha_ynca.DOMAIN not in hass.data @patch("homeassistant.config_entries.ConfigEntries.async_reload") diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 4359ef8..4959330 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -174,7 +174,7 @@ async def test_options_flow_no_connection(hass: HomeAssistant, mock_ynca) -> Non """Test optionsflow when there is no connection""" integration = await setup_integration(hass, mock_ynca) - hass.data[yamaha_ynca.DOMAIN] = {} # Pretend connection failed + integration.entry.runtime_data = None # Pretend connection failed result = await hass.config_entries.options.async_init(integration.entry.entry_id) From 90d93921cd6233f97763e6df823501d6d9767e00 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 17:07:40 +0200 Subject: [PATCH 08/16] Implement reconfigure and misc optionsflow fixes (#306) * Change reauth to reconfigure * Fix wrong usage of "is" * Update options flow for reauth and runtimedata changes * Fix more deprecated assignments to config_entry.options * Translation improvement * Format config_flow * Remove unused imports * Improve error message --- custom_components/yamaha_ynca/config_flow.py | 59 ++++++++++++++----- custom_components/yamaha_ynca/options_flow.py | 19 +++--- custom_components/yamaha_ynca/strings.json | 13 ++-- .../yamaha_ynca/translations/en.json | 13 ++-- tests/test_button.py | 5 +- tests/test_config_flow.py | 17 ++++-- tests/test_media_player.py | 2 +- tests/test_options_flow.py | 24 +++++--- tests/test_select.py | 13 ++-- 9 files changed, 108 insertions(+), 57 deletions(-) diff --git a/custom_components/yamaha_ynca/config_flow.py b/custom_components/yamaha_ynca/config_flow.py index 1d5b230..40037fa 100644 --- a/custom_components/yamaha_ynca/config_flow.py +++ b/custom_components/yamaha_ynca/config_flow.py @@ -3,13 +3,15 @@ from __future__ import annotations from typing import Any, Dict +import re import voluptuous as vol # type: ignore import ynca -from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.core import HomeAssistant, callback +from . import YamahaYncaConfigEntry from .const import ( CONF_HOST, CONF_PORT, @@ -72,7 +74,7 @@ class YamahaYncaConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 7 MINOR_VERSION = 5 - reauth_entry: ConfigEntry | None = None + reconfigure_entry: YamahaYncaConfigEntry | None = None @staticmethod @callback @@ -106,17 +108,20 @@ async def async_try_connect( LOGGER.exception("Unhandled exception during connection.") errors["base"] = "unknown" else: - data = {} - data[CONF_SERIAL_URL] = user_input[CONF_SERIAL_URL] - data[DATA_MODELNAME] = check_result.modelname - data[DATA_ZONES] = check_result.zones + data = { + CONF_SERIAL_URL: user_input[CONF_SERIAL_URL], + DATA_MODELNAME: check_result.modelname, + DATA_ZONES: check_result.zones, + } - if self.reauth_entry: + if self.reconfigure_entry: self.hass.config_entries.async_update_entry( - self.reauth_entry, data=data + self.reconfigure_entry, data=data + ) + await self.hass.config_entries.async_reload( + self.reconfigure_entry.entry_id ) - await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) - return self.async_abort(reason="reauth_successful") + return self.async_abort(reason="reconfigure_successful") return self.async_create_entry(title=check_result.modelname, data=data) @@ -131,7 +136,15 @@ async def async_step_serial( ) -> ConfigFlowResult: if user_input is None: return self.async_show_form( - step_id=STEP_ID_SERIAL, data_schema=get_serial_url_schema({}) + step_id=STEP_ID_SERIAL, + data_schema=get_serial_url_schema( + {CONF_SERIAL_URL: self.reconfigure_entry.data.get(CONF_SERIAL_URL)} + if self.reconfigure_entry + and not self.reconfigure_entry.data[CONF_SERIAL_URL].startswith( + "socket://" + ) + else {} + ), ) return await self.async_try_connect( @@ -142,8 +155,18 @@ async def async_step_network( self, user_input: Dict[str, Any] | None = None ) -> ConfigFlowResult: if user_input is None: + data = {} + if self.reconfigure_entry: + # Get HOST and PORT from socket://HOST:PORT + if m := re.match( + r"socket://(?P.+):(?P\d+)", + self.reconfigure_entry.data[CONF_SERIAL_URL], + ): + data[CONF_HOST] = m.group("host") + data[CONF_PORT] = int(m.group("port")) + return self.async_show_form( - step_id=STEP_ID_NETWORK, data_schema=get_network_schema({}) + step_id=STEP_ID_NETWORK, data_schema=get_network_schema(data) ) connection_data = { @@ -158,16 +181,20 @@ async def async_step_advanced( ) -> ConfigFlowResult: if user_input is None: return self.async_show_form( - step_id=STEP_ID_ADVANCED, data_schema=get_serial_url_schema({}) + step_id=STEP_ID_ADVANCED, + data_schema=get_serial_url_schema( + {CONF_SERIAL_URL: self.reconfigure_entry.data.get(CONF_SERIAL_URL)} + if self.reconfigure_entry + else {} + ), ) return await self.async_try_connect( STEP_ID_ADVANCED, get_serial_url_schema(user_input), user_input ) - async def async_step_reauth(self, user_input=None): - """Reauth is (ab)used to allow setting up connection settings again through the existing flow.""" - self.reauth_entry = self.hass.config_entries.async_get_entry( + async def async_step_reconfigure(self, user_input=None): + self.reconfigure_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) diff --git a/custom_components/yamaha_ynca/options_flow.py b/custom_components/yamaha_ynca/options_flow.py index 72ded0c..cbafa69 100644 --- a/custom_components/yamaha_ynca/options_flow.py +++ b/custom_components/yamaha_ynca/options_flow.py @@ -5,7 +5,6 @@ import voluptuous as vol # type: ignore import ynca -from custom_components.yamaha_ynca.input_helpers import InputHelper from homeassistant import config_entries import homeassistant.helpers.config_validation as cv @@ -18,13 +17,11 @@ CONF_SELECTED_SURROUND_DECODERS, DATA_MODELNAME, DATA_ZONES, - DOMAIN, - LOGGER, MAX_NUMBER_OF_SCENES, NUMBER_OF_SCENES_AUTODETECT, - SURROUNDDECODEROPTIONS_PLIIX_MAPPING, TWOCHDECODER_STRINGS, ) +from .input_helpers import InputHelper STEP_ID_INIT = "init" STEP_ID_NO_CONNECTION = "no_connection" @@ -75,8 +72,11 @@ async def do_next_step(self, current_step_id: str): async def async_step_init(self, user_input=None): """Basic sanity checks before configuring options.""" - if runtime_data := self.config_entry.runtime_data: - self.api = runtime_data.api + # The configentry in the optionsflow is _only_ a YamahaYncaConfigEntry when there is a connection + # Otherwise it is a "plain" ConfigEntry, so without runtime_data + # A normal isinstance check does not seem to work with type alias, to check for runtime_data attribute + if getattr(self.config_entry, "runtime_data", None): + self.api = self.config_entry.runtime_data.api return await self.async_step_general() return await self.async_step_no_connection() @@ -84,8 +84,11 @@ async def async_step_init(self, user_input=None): async def async_step_no_connection(self, user_input=None): """No connection dialog""" if user_input is not None: - self.config_entry.async_start_reauth(self.hass) - return self.async_abort(reason="marked_for_reconfiguring") + # Strangely enough there is no title on the abort box + # I guess because optionflows are not expected to be aborted + # So exit with "success" instead through the done step and it will rewrite current settings + # return self.async_abort(reason="no_connection") + return await self.async_step_done() return self.async_show_form(step_id=STEP_ID_NO_CONNECTION) diff --git a/custom_components/yamaha_ynca/strings.json b/custom_components/yamaha_ynca/strings.json index 6c8bcd2..ef20de8 100644 --- a/custom_components/yamaha_ynca/strings.json +++ b/custom_components/yamaha_ynca/strings.json @@ -17,7 +17,7 @@ }, "network": { "title": "Network connection", - "description": "Input the IP address or hostname of the receiver and the port.\n\nLeave the port at default 50000 unless you have configured a different port on the receiver.", + "description": "Input the IP address or hostname of the receiver.\n\nLeave the port at default 50000 unless you have configured a different port on the receiver.", "data": { "host": "Receiver IP address or hostname e.g. 192.168.1.123", "port": "YNCA port; default is 50000" @@ -32,15 +32,15 @@ } }, "error": { - "connection_error": "Failed to connect, check settings.", + "connection_error": "Failed to connect, check settings and make sure this is the _only_ application connecting to the receiver with the YNCA protocol.", "connection_failed_serial": "Connection failed, check serial port.", - "connection_failed_network": "Connection failed, check IP address and port settings and make sure this is the _only_ device using YNCA on the receiver.", + "connection_failed_network": "Connection failed, check IP address and port settings.", "connection_failed_advanced": "Connection failed, check URL handler format.", "unknown": "Unexpected error." }, "abort": { "already_configured": "Device is already configured", - "reauth_successful": "Successfully reconfigured connection" + "reconfigure_successful": "Successfully re-configured the integration" } }, "options": { @@ -87,11 +87,8 @@ }, "no_connection": { "title": "No connection", - "description": "Could not connect to the receiver.\n\nClick 'Submit' to mark the integration for reconfiguration to be able to change the connection settings (or close this window to do nothing)." + "description": "Can not configure receiver without active connection.\n\nUse the re-configure option to update connection settings if needed." } - }, - "abort": { - "marked_for_reconfiguring": "Configentry is marked for reconfiguration. You might need to refresh the browser." } }, "entity": { diff --git a/custom_components/yamaha_ynca/translations/en.json b/custom_components/yamaha_ynca/translations/en.json index 6c8bcd2..ef20de8 100644 --- a/custom_components/yamaha_ynca/translations/en.json +++ b/custom_components/yamaha_ynca/translations/en.json @@ -17,7 +17,7 @@ }, "network": { "title": "Network connection", - "description": "Input the IP address or hostname of the receiver and the port.\n\nLeave the port at default 50000 unless you have configured a different port on the receiver.", + "description": "Input the IP address or hostname of the receiver.\n\nLeave the port at default 50000 unless you have configured a different port on the receiver.", "data": { "host": "Receiver IP address or hostname e.g. 192.168.1.123", "port": "YNCA port; default is 50000" @@ -32,15 +32,15 @@ } }, "error": { - "connection_error": "Failed to connect, check settings.", + "connection_error": "Failed to connect, check settings and make sure this is the _only_ application connecting to the receiver with the YNCA protocol.", "connection_failed_serial": "Connection failed, check serial port.", - "connection_failed_network": "Connection failed, check IP address and port settings and make sure this is the _only_ device using YNCA on the receiver.", + "connection_failed_network": "Connection failed, check IP address and port settings.", "connection_failed_advanced": "Connection failed, check URL handler format.", "unknown": "Unexpected error." }, "abort": { "already_configured": "Device is already configured", - "reauth_successful": "Successfully reconfigured connection" + "reconfigure_successful": "Successfully re-configured the integration" } }, "options": { @@ -87,11 +87,8 @@ }, "no_connection": { "title": "No connection", - "description": "Could not connect to the receiver.\n\nClick 'Submit' to mark the integration for reconfiguration to be able to change the connection settings (or close this window to do nothing)." + "description": "Can not configure receiver without active connection.\n\nUse the re-configure option to update connection settings if needed." } - }, - "abort": { - "marked_for_reconfiguring": "Configentry is marked for reconfiguration. You might need to refresh the browser." } }, "entity": { diff --git a/tests/test_button.py b/tests/test_button.py index 9c282ca..31449e1 100644 --- a/tests/test_button.py +++ b/tests/test_button.py @@ -56,7 +56,10 @@ async def test_async_setup_entry_configured_number_of_scenes( integration = await setup_integration(hass, mock_ynca) options = dict(integration.entry.options) options["ZONE2"] = {yamaha_ynca.const.CONF_NUMBER_OF_SCENES: 11} - integration.entry.options = options + hass.config_entries.async_update_entry( + integration.entry, + options=options + ) add_entities_mock = Mock() await async_setup_entry(hass, integration.entry, add_entities_mock) diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 9c32c1b..1a25184 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -153,17 +153,26 @@ async def test_unhandled_exception(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "unknown"} -async def test_reauth(hass: HomeAssistant, mock_ynca) -> None: +async def test_reconfigure(hass: HomeAssistant, mock_ynca) -> None: integration = await setup_integration(hass, mock_ynca) + # Make sure existing data is different from what we are changing it to + hass.config_entries.async_update_entry( + integration.entry, + data={ + **integration.entry.data, + yamaha_ynca.const.CONF_SERIAL_URL: "socket://old_hostname_or_ipaddress:12345" + } + ) + # Flow goes to menu with connection options result = await hass.config_entries.flow.async_init( - yamaha_ynca.DOMAIN, context={"source": config_entries.SOURCE_REAUTH, "entry_id": integration.entry.entry_id} + yamaha_ynca.DOMAIN, context={"source": config_entries.SOURCE_RECONFIGURE, "entry_id": integration.entry.entry_id} ) assert result["type"] == FlowResultType.MENU - # Select network (could be any) + # Select network for this test result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": "network"}, @@ -194,4 +203,4 @@ async def test_reauth(hass: HomeAssistant, mock_ynca) -> None: # Entry got updated and flow is aborted (as intended) assert integration.entry.data[yamaha_ynca.const.CONF_SERIAL_URL] == "socket://hostname_or_ipaddress:44444" assert result2["type"] == FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" + assert result2["reason"] == "reconfigure_successful" diff --git a/tests/test_media_player.py b/tests/test_media_player.py index 1173734..e6daac6 100644 --- a/tests/test_media_player.py +++ b/tests/test_media_player.py @@ -466,7 +466,7 @@ async def test_mediaplayer_mediainfo(mp_entity: YamahaYncaZone, mock_zone, mock_ mock_ynca.netradio.station = "StationName" mock_ynca.netradio.song = "SongName" mock_ynca.netradio.album = "AlbumName" - assert mp_entity.media_title is "SongName" + assert mp_entity.media_title == "SongName" assert mp_entity.media_channel == "StationName" assert mp_entity.media_album_name == "AlbumName" assert mp_entity.media_content_type is MediaType.CHANNEL diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 4959330..17d9eef 100644 --- a/tests/test_options_flow.py +++ b/tests/test_options_flow.py @@ -87,7 +87,6 @@ async def test_options_flow_navigate_all_screens( mock_ynca.zone4 = mock_zone_zone4 integration = await setup_integration(hass, mock_ynca) - integration.entry.options = dict(integration.entry.options) result = await hass.config_entries.options.async_init(integration.entry.entry_id) @@ -187,8 +186,7 @@ async def test_options_flow_no_connection(hass: HomeAssistant, mock_ynca) -> Non user_input={}, ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "marked_for_reconfiguring" + assert result["type"] == FlowResultType.CREATE_ENTRY async def test_options_flow_soundmodes(hass: HomeAssistant, mock_ynca) -> None: @@ -201,7 +199,10 @@ async def test_options_flow_soundmodes(hass: HomeAssistant, mock_ynca) -> None: options[yamaha_ynca.const.CONF_HIDDEN_SOUND_MODES] = [ "Obsolete", # Obsolete values should not break the schema ] - integration.entry.options = options + hass.config_entries.async_update_entry( + integration.entry, + options=options + ) result = await hass.config_entries.options.async_init(integration.entry.entry_id) @@ -252,7 +253,10 @@ async def test_options_flow_surrounddecoders(hass: HomeAssistant, mock_ynca, moc options = dict(integration.entry.options) # Do _not_ set options[yamaha_ynca.const.CONF_SELECTED_SURROUND_DECODERS] to test handling of absent options - integration.entry.options = options + hass.config_entries.async_update_entry( + integration.entry, + options=options + ) result = await hass.config_entries.options.async_init(integration.entry.entry_id) @@ -296,7 +300,10 @@ async def test_options_flow_zone_inputs( integration = await setup_integration(hass, mock_ynca) options = dict(integration.entry.options) options["MAIN"] = {"hidden_inputs": ["AV5"]} - integration.entry.options = options + hass.config_entries.async_update_entry( + integration.entry, + options=options + ) result = await hass.config_entries.options.async_init(integration.entry.entry_id) assert result["step_id"] == "general" @@ -336,7 +343,10 @@ async def test_options_flow_configure_nof_scenes( integration = await setup_integration(hass, mock_ynca) options = dict(integration.entry.options) options["MAIN"] = {"number_of_scenes": 5} - integration.entry.options = options + hass.config_entries.async_update_entry( + integration.entry, + options=options + ) result = await hass.config_entries.options.async_init(integration.entry.entry_id) assert result["step_id"] == "general" diff --git a/tests/test_select.py b/tests/test_select.py index b1a6b24..6d4af45 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from unittest.mock import Mock +from homeassistant.core import HomeAssistant import ynca @@ -233,11 +234,15 @@ async def test_select_surrounddecoder_entity_options_nothing_selection_in_config async def test_select_surrounddecoder_entity_options_some_selected_in_configentry( - mock_zone: ZoneBase, mock_config_entry + hass: HomeAssistant, mock_zone: ZoneBase, mock_config_entry ): - mock_config_entry.options = { - CONF_SELECTED_SURROUND_DECODERS: ["dolby_pl", "auto", "dolby_plii_movie"] - } + await hass.config_entries.async_add(mock_config_entry) + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + CONF_SELECTED_SURROUND_DECODERS: ["dolby_pl", "auto", "dolby_plii_movie"] + }, + ) entity = YamahaYncaSelectSurroundDecoder( mock_config_entry, From 30bad03132df239a1a92e763f01fe41441da89f9 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 17:22:18 +0200 Subject: [PATCH 09/16] Translation improvements (#307) --- custom_components/yamaha_ynca/strings.json | 222 ------------------ .../yamaha_ynca/translations/en.json | 2 +- 2 files changed, 1 insertion(+), 223 deletions(-) delete mode 100644 custom_components/yamaha_ynca/strings.json diff --git a/custom_components/yamaha_ynca/strings.json b/custom_components/yamaha_ynca/strings.json deleted file mode 100644 index ef20de8..0000000 --- a/custom_components/yamaha_ynca/strings.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "config": { - "step": { - "user": { - "title": "Select connection method", - "menu_options": { - "serial": "Serial connection", - "network": "Network connection", - "advanced": "PySerial URL handler (advanced)" - } - }, - "serial": { - "title": "Serial connection", - "data": { - "serial_url": "Serial port e.g. /dev/ttyUSB0" - } - }, - "network": { - "title": "Network connection", - "description": "Input the IP address or hostname of the receiver.\n\nLeave the port at default 50000 unless you have configured a different port on the receiver.", - "data": { - "host": "Receiver IP address or hostname e.g. 192.168.1.123", - "port": "YNCA port; default is 50000" - } - }, - "advanced": { - "title": "PySerial URL handler (advanced)", - "description": "Provide any [URL handler as supported by PySerial](https://pyserial.readthedocs.io/en/latest/url_handlers.html).\n\nThis can come in handy when addressing USB adapters by serial with `hwgrep://` or use `rfc2217://hostname_or_ip` to connect through an rfc2217 compatible server.", - "data": { - "serial_url": "URL handler" - } - } - }, - "error": { - "connection_error": "Failed to connect, check settings and make sure this is the _only_ application connecting to the receiver with the YNCA protocol.", - "connection_failed_serial": "Connection failed, check serial port.", - "connection_failed_network": "Connection failed, check IP address and port settings.", - "connection_failed_advanced": "Connection failed, check URL handler format.", - "unknown": "Unexpected error." - }, - "abort": { - "already_configured": "Device is already configured", - "reconfigure_successful": "Successfully re-configured the integration" - } - }, - "options": { - "step": { - "general": { - "title": "General settings", - "description": "Select the options that are supported by your receiver.", - "data": { - "selected_sound_modes": "Sound modes", - "selected_surround_decoders": "Surround decoders" - } - }, - "main": { - "title": "Main zone settings", - "description": "Select the options that are applicable for the main zone of the receiver.", - "data": { - "selected_inputs": "Inputs", - "number_of_scenes": "Number of scenes" - } - }, - "zone2": { - "title": "Zone 2 settings", - "description": "Select the options that are applicable for the zone 2 of the receiver.", - "data": { - "selected_inputs": "Inputs", - "number_of_scenes": "Number of scenes" - } - }, - "zone3": { - "title": "Zone 3 settings", - "description": "Select the options that are applicable for the zone 3 of the receiver.", - "data": { - "selected_inputs": "Inputs", - "number_of_scenes": "Number of scenes" - } - }, - "zone4": { - "title": "Zone 4 settings", - "description": "Select the options that are applicable for the zone 4 of the receiver.", - "data": { - "selected_inputs": "Inputs", - "number_of_scenes": "Number of scenes" - } - }, - "no_connection": { - "title": "No connection", - "description": "Can not configure receiver without active connection.\n\nUse the re-configure option to update connection settings if needed." - } - } - }, - "entity": { - "number": { - "hpbass": { - "name": "Headphones bass" - }, - "hptreble": { - "name": "Headphones treble" - }, - "initvollvl": { - "name": "Initial Volume" - }, - "maxvol": { - "name": "Max Volume" - }, - "spbass": { - "name": "Speaker bass" - }, - "sptreble": { - "name": "Speaker treble" - }, - "vol": { - "name": "Volume (dB)" - } - }, - "remote": { - "main": { - "name": "Remote" - }, - "zone2": { - "name": "Remote" - }, - "zone3": { - "name": "Remote" - }, - "zone4": { - "name": "Remote" - } - }, - "switch": { - "adaptivedrc": { - "name": "Adaptive DRC" - }, - "enhancer": { - "name": "Compressed Music Enhancer" - }, - "hdmiout1": { - "name": "HDMI Out 1" - }, - "hdmiout2": { - "name": "HDMI Out 2" - }, - "puredirmode": { - "name": "Pure Direct" - }, - "threedcinema": { - "name": "CINEMA DSP 3D Mode" - }, - "hdmiout": { - "name": "HDMI Out" - } - }, - "select": { - "hdmiout": { - "name": "HDMI Out", - "state": { - "off": "Off", - "out1": "HDMI OUT 1", - "out2": "HDMI OUT 2", - "out1_2": "HDMI OUT 1 + 2" - } - }, - "initial_volume_mode": { - "name": "Initial Volume Mode", - "state": { - "last_value": "Last value", - "mute": "Muted", - "configured_initial_volume": "Configured initial volume" - } - }, - "sleep": { - "name": "Sleep timer", - "state": { - "off": "Off", - "30_min": "30 Minutes", - "60_min": "60 Minutes", - "90_min": "90 Minutes", - "120_min": "120 Minutes" - } - }, - "twochdecoder": { - "name": "Surround Decoder", - "state": { - "dolby_pl": "Dolby Pro Logic", - "dolby_plii_game": "Dolby Pro Logic II(x) Game", - "dolby_plii_movie": "Dolby Pro Logic II(x) Movie", - "dolby_plii_music": "Dolby Pro Logic II(x) Music", - "dts_neo_6_cinema": "DTS NEO:6 Cinema", - "dts_neo_6_music": "DTS NEO:6 Music", - "auto": "Auto", - "dolby_surround": "Dolby Surround", - "dts_neural_x": "DTS Neural:X", - "auro_3d": "AURO-3D" - } - } - } - }, - "services": { - "send_raw_ynca": { - "name": "Send raw YNCA command", - "description": "Send raw YNCA commands, intended for debugging. Responses can be seen in the 'history' part of the diagnostics file or in the Home Assistant logs after enabling debug logging on the Yamaha (YNCA) integration.", - "fields": { - "raw_data": { - "name": "Raw YNCA data", - "description": "Raw YNCA data to send. One command per line. Needs to follow YNCA format @SUBUNIT:FUNCTION=VALUE" - } - } - }, - "store_preset": { - "name": "Store preset", - "description": "Store a preset for the current input.", - "fields": { - "preset_id": { - "name": "Preset number", - "description": "Preset number to store, must be in range 1 to 40" - } - } - } - } -} \ No newline at end of file diff --git a/custom_components/yamaha_ynca/translations/en.json b/custom_components/yamaha_ynca/translations/en.json index ef20de8..9fe1ba0 100644 --- a/custom_components/yamaha_ynca/translations/en.json +++ b/custom_components/yamaha_ynca/translations/en.json @@ -87,7 +87,7 @@ }, "no_connection": { "title": "No connection", - "description": "Can not configure receiver without active connection.\n\nUse the re-configure option to update connection settings if needed." + "description": "Can not configure integration without active connection to the receiver.\n\nUse the re-configure option to update connection settings if needed." } } }, From 2621e60417efc75b4f9f6c1f96792e47c4340fc9 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 17:30:05 +0200 Subject: [PATCH 10/16] Update README.md remove experimental note about DAB preset (#308) No complaints, so it must be working :D --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 7f794cf..1c3b616 100644 --- a/README.md +++ b/README.md @@ -518,9 +518,6 @@ cards: ## Presets -> [!NOTE] -> Presets for DAB tuner are currently experimental. The DAB tuner uses different commands from the other inputs so I had to guess a bit on how it works and might have been wrong. I am unable to test it because my receiver does not support DAB. Please provide feedback in the [Discussions](https://github.com/mvdwetering/yamaha_ynca/discussions) or [Issues](https://github.com/mvdwetering/yamaha_ynca/issues). - Presets can be activated and stored with the integration on many inputs. The most obvious inputs that support presets are the radio inputs like AM/FM tuner. Due to limitations on the protocol the integration can only show the preset number, no name or what is stored. Inputs that support presets are: Napster, Netradio, Pandora, PC, Rhapsody, Sirius, SiriusIR, Tuner and USB. Presets can be selected in the mediabrowser of the mediaplayer or in automations with the `media_player.play_media` service. When selecting a preset the receiver will turn on and switch input if needed. From 31d6425b680b3f16946306150856078e8eddc2e9 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 17:41:32 +0200 Subject: [PATCH 11/16] Update README.md remove obsolete info and minor tweaks (#309) --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 1c3b616..dce35dd 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,7 @@ target: ### Media content format -In some cases it is not possible to select presets from the UI and it is needed to provide the `media_content_id` and `media_content_type` manually. +In some cases it is not possible to select presets from the UI and it is needed to manually provide the `media_content_id` and `media_content_type`. The `media_content_type` is always "music". The `media_content_id` format is listed in the table below. Replace the "1" at the end with the preset number you need. @@ -562,15 +562,22 @@ The `media_content_type` is always "music". The `media_content_id` format is lis The receiver does not allow changing of settings when it is in standby, so the entities become Unavailable in Home Assistant to indicate this. * **Q: Why does the integration shows too many or not enough features that are available on my receiver?** - The integration tries to autodetect as many features as possible, but it is not possible for all features on all receivers. For example, supported soundmodes, available inputs, scenes or surround decoders cannot always be detected. You can adjust these for your receiver in the integration configuration. + The integration tries to autodetect as many features as possible, but it is not possible for all features on all receivers. For example, supported soundmodes, available inputs, scenes or surround decoders cannot always be detected. You can adjust these features for your receiver in the integration configuration. -* **Q: Why are Scene buttons are not working?** - On some receivers (e.g. RX-V475 with firmware 1.34/2.06) the command to activate the scenes does not work even though the receiver indicates support for them. There might be more receivers with this issue, please report them in an issue or start a discussion. The non-working buttons can be disabled in the integration configuration by selecting "0" instead of "Auto detect". +* **Q: Why are Scene buttons are not working on some receivers?** + On some receivers (e.g. RX-V475 with firmware 1.34/2.06) the command to activate the scenes does not work even though the receiver indicates support for them. There might be more receivers with this issue, please report them in an issue or start a discussion. - As an alternative the scenes can be activated by sending the scene commands through the [Remote control entity](#remote-control). + The non-working buttons can be disabled in the integration configuration by selecting "0" for number of scenes instead of "Auto detect". -* **Q: How can I fix the connection settings if the connection is not working?** - When the integration cannot connect to the receiver (e.g. due to changed IP address) you can use the "Configure" button on the integration card. A dialog will appear with a message that it can't connect. Press "Submit" in this dialog to mark the integration for reconfiguration. Home Assistant will now allow you to reconfigure the integration (reload of the page in the browser seems required to show the reconfigure card). + As an alternative the scenes can be activated by sending the scene commands through service calls on the [Remote control entity](#remote-control). + +```yaml +service: remote.send_command +data: + command: scene_1 +target: + entity_id: remote.rx_V475_main_remote +``` * **Q: How can I stream audio from a URL?** You can't with this integration since the protocol does not support that. You might be able to use the [DLNA Digital Media Renderer integration](https://www.home-assistant.io/integrations/dlna_dmr/) that comes with Home Assistant. From 5156577e180af8117dd5838d74fb2a56bbf4c3f6 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Wed, 24 Jul 2024 18:00:22 +0200 Subject: [PATCH 12/16] Also check if HDMOUT is supported. Next to HDMI2 available or not (#310) --- custom_components/yamaha_ynca/select.py | 6 ++++-- custom_components/yamaha_ynca/switch.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/custom_components/yamaha_ynca/select.py b/custom_components/yamaha_ynca/select.py index cd92c26..d363311 100644 --- a/custom_components/yamaha_ynca/select.py +++ b/custom_components/yamaha_ynca/select.py @@ -221,8 +221,10 @@ def is_supported(self, zone_subunit: ZoneBase): ], # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output # This select handles multiple HDMI outputs, so check if HDMI2 exists to see if it is supported - supported_check=lambda _, zone_subunit: zone_subunit.lipsynchdmiout2offset - is not None, + supported_check=lambda _, zone_subunit: ( + getattr(zone_subunit, "hdmiout", None) is not None + and zone_subunit.lipsynchdmiout2offset is not None + ), ), YncaSelectEntityDescription( # type: ignore key="sleep", diff --git a/custom_components/yamaha_ynca/switch.py b/custom_components/yamaha_ynca/switch.py index a8419cf..5df812e 100644 --- a/custom_components/yamaha_ynca/switch.py +++ b/custom_components/yamaha_ynca/switch.py @@ -82,8 +82,10 @@ def is_supported(self, zone_subunit: ZoneBase): off=ynca.HdmiOut.OFF, # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output # This switch handles single HDMI output, so check if HDMI2 does NOT exist and assume there is only one HDMI output - supported_check=lambda _, zone_subunit: zone_subunit.lipsynchdmiout2offset - is None, + supported_check=lambda _, zone_subunit: ( + getattr(zone_subunit, "hdmiout", None) is not None + and zone_subunit.lipsynchdmiout2offset is None + ), ), ] From 1c166f52c4be31307eaa6cc17c60aa42ea3ee804 Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 26 Jul 2024 20:52:43 +0200 Subject: [PATCH 13/16] Add more tests for hdmiout (#311) * Add mote tests for hdmiout * Refactor to avoid repetition --- custom_components/yamaha_ynca/helpers.py | 4 +++ custom_components/yamaha_ynca/select.py | 11 +++---- custom_components/yamaha_ynca/switch.py | 11 +++---- tests/test_select.py | 35 ++++++++++++++++++++ tests/test_switch.py | 42 ++++++++++++++++++++++-- 5 files changed, 87 insertions(+), 16 deletions(-) diff --git a/custom_components/yamaha_ynca/helpers.py b/custom_components/yamaha_ynca/helpers.py index 095449e..7024d81 100644 --- a/custom_components/yamaha_ynca/helpers.py +++ b/custom_components/yamaha_ynca/helpers.py @@ -52,6 +52,10 @@ def receiver_requires_audio_input_workaround(modelname) -> bool: "HTR-5065", ] +def subunit_supports_entitydescription_key(entity_description, subunit) -> bool: + return getattr( + subunit, entity_description.key, None + ) is not None class YamahaYncaSettingEntity: """ diff --git a/custom_components/yamaha_ynca/select.py b/custom_components/yamaha_ynca/select.py index d363311..6853fb3 100644 --- a/custom_components/yamaha_ynca/select.py +++ b/custom_components/yamaha_ynca/select.py @@ -20,7 +20,7 @@ TWOCHDECODER_STRINGS, ZONE_ATTRIBUTE_NAMES, ) -from .helpers import YamahaYncaSettingEntity +from .helpers import YamahaYncaSettingEntity, subunit_supports_entitydescription_key if TYPE_CHECKING: # pragma: no cover from ynca.subunit import SubunitBase @@ -190,10 +190,9 @@ class YncaSelectEntityDescription(SelectEntityDescription): """YamahaYncaSelect class to instantiate for this entity_description""" supported_check: Callable[[YncaSelectEntityDescription, ZoneBase], bool] = ( - lambda entity_description, zone_subunit: getattr( - zone_subunit, entity_description.key, None + lambda entity_description, zone_subunit: subunit_supports_entitydescription_key( + entity_description, zone_subunit ) - is not None ) """Callable to check support for this entity on the zone, default checks if attribute `key` is not None.""" @@ -221,8 +220,8 @@ def is_supported(self, zone_subunit: ZoneBase): ], # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output # This select handles multiple HDMI outputs, so check if HDMI2 exists to see if it is supported - supported_check=lambda _, zone_subunit: ( - getattr(zone_subunit, "hdmiout", None) is not None + supported_check=lambda entity_description, zone_subunit: ( + subunit_supports_entitydescription_key(entity_description, zone_subunit) and zone_subunit.lipsynchdmiout2offset is not None ), ), diff --git a/custom_components/yamaha_ynca/switch.py b/custom_components/yamaha_ynca/switch.py index 5df812e..1447452 100644 --- a/custom_components/yamaha_ynca/switch.py +++ b/custom_components/yamaha_ynca/switch.py @@ -13,7 +13,7 @@ from . import YamahaYncaConfigEntry from .const import ZONE_ATTRIBUTE_NAMES -from .helpers import YamahaYncaSettingEntity +from .helpers import YamahaYncaSettingEntity, subunit_supports_entitydescription_key if TYPE_CHECKING: # pragma: no cover from ynca.subunits.zone import ZoneBase @@ -32,10 +32,7 @@ class YncaSwitchEntityDescription(SwitchEntityDescription): Such relation is indicated here """ supported_check: Callable[[YncaSwitchEntityDescription, ZoneBase], bool] = ( - lambda entity_description, zone_subunit: getattr( - zone_subunit, entity_description.key, None - ) - is not None + lambda entity_description, zone_subunit: subunit_supports_entitydescription_key(entity_description, zone_subunit) ) """ Callable to check support for this entity on the zone, default checks if attribute `key` is not None. @@ -82,8 +79,8 @@ def is_supported(self, zone_subunit: ZoneBase): off=ynca.HdmiOut.OFF, # HDMIOUT is used for receivers with multiple HDMI outputs and single HDMI output # This switch handles single HDMI output, so check if HDMI2 does NOT exist and assume there is only one HDMI output - supported_check=lambda _, zone_subunit: ( - getattr(zone_subunit, "hdmiout", None) is not None + supported_check=lambda entity_description, zone_subunit: ( + subunit_supports_entitydescription_key(entity_description, zone_subunit) and zone_subunit.lipsynchdmiout2offset is None ), ), diff --git a/tests/test_select.py b/tests/test_select.py index 6d4af45..80f4c7e 100644 --- a/tests/test_select.py +++ b/tests/test_select.py @@ -252,3 +252,38 @@ async def test_select_surrounddecoder_entity_options_some_selected_in_configentr ) assert entity.options == ["auto", "dolby_pl", "dolby_plii_movie"] + + +async def test_hdmiout_not_supported_at_all(hass, mock_ynca, mock_zone_main): + mock_ynca.main = mock_zone_main + mock_ynca.main.hdmiout = None + mock_ynca.main.lipsynchdmiout2offset = None + + await setup_integration(hass, mock_ynca) + + hdmiout = hass.states.get("select.modelname_main_hdmi_out") + assert hdmiout is None + + +async def test_hdmiout_supported_with_one_hdmi_output(hass, mock_ynca, mock_zone_main): + mock_ynca.main = mock_zone_main + mock_ynca.main.hdmiout = ynca.HdmiOut.OFF + mock_ynca.main.lipsynchdmiout2offset = None # This indicates no HDMI2 + + await setup_integration(hass, mock_ynca) + + hdmiout = hass.states.get("select.modelname_main_hdmi_out") + assert hdmiout is None + + +async def test_hdmiout_supported_but_with_two_hdmi_outputs( + hass, mock_ynca, mock_zone_main +): + mock_ynca.main = mock_zone_main + mock_ynca.main.hdmiout = ynca.HdmiOut.OFF + mock_ynca.main.lipsynchdmiout2offset = 123 # This indicates HDMI2 + + await setup_integration(hass, mock_ynca) + + hdmiout = hass.states.get("select.modelname_main_hdmi_out") + assert hdmiout is not None diff --git a/tests/test_switch.py b/tests/test_switch.py index a8b67be..c12ce5f 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -14,7 +14,6 @@ from tests.conftest import setup_integration - TEST_ENTITY_DESCRIPTION = YncaSwitchEntityDescription( key="enhancer", entity_category=EntityCategory.CONFIG, @@ -28,7 +27,7 @@ name="Name", on=ynca.HdmiOutOnOff.ON, off=ynca.HdmiOutOnOff.OFF, - associated_zone_attr="main" + associated_zone_attr="main", ) @@ -94,7 +93,9 @@ async def test_switch_associated_zone_handling(mock_ynca, mock_zone_main): mock_sys = mock_ynca.sys mock_main = mock_zone_main - entity = YamahaYncaSwitch("ReceiverUniqueId", mock_sys, TEST_ENTITY_DESCRIPTION_ASSOCIATED_ZONE, mock_main) + entity = YamahaYncaSwitch( + "ReceiverUniqueId", mock_sys, TEST_ENTITY_DESCRIPTION_ASSOCIATED_ZONE, mock_main + ) assert entity.unique_id == "ReceiverUniqueId_SYS_hdmiout1" assert entity.device_info["identifiers"] == { @@ -112,3 +113,38 @@ async def test_switch_associated_zone_handling(mock_ynca, mock_zone_main): assert entity.is_on is True mock_sys.hdmiout1 = ynca.HdmiOutOnOff.OFF assert entity.is_on is False + + +async def test_hdmiout_not_supported_at_all(hass, mock_ynca, mock_zone_main): + mock_ynca.main = mock_zone_main + mock_ynca.main.hdmiout = None + mock_ynca.main.lipsynchdmiout2offset = None + + await setup_integration(hass, mock_ynca) + + hdmiout = hass.states.get("switch.modelname_main_hdmi_out") + assert hdmiout is None + + +async def test_hdmiout_supported_with_one_hdmi_output(hass, mock_ynca, mock_zone_main): + mock_ynca.main = mock_zone_main + mock_ynca.main.hdmiout = ynca.HdmiOut.OFF + mock_ynca.main.lipsynchdmiout2offset = None # This indicates no HDMI2 + + await setup_integration(hass, mock_ynca) + + hdmiout = hass.states.get("switch.modelname_main_hdmi_out") + assert hdmiout is not None + + +async def test_hdmiout_supported_but_with_two_hdmi_outputs( + hass, mock_ynca, mock_zone_main +): + mock_ynca.main = mock_zone_main + mock_ynca.main.hdmiout = ynca.HdmiOut.OFF + mock_ynca.main.lipsynchdmiout2offset = 123 # This indicates HDMI2 + + await setup_integration(hass, mock_ynca) + + hdmiout = hass.states.get("switch.modelname_main_hdmi_out") + assert hdmiout is None From 9ea4822302c43d7d42ee676b362bbdfbb9eb6c8b Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 26 Jul 2024 21:36:39 +0200 Subject: [PATCH 14/16] Update README.md (#312) * Update README.md * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update README.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- README.md | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index dce35dd..f802dd6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Custom integration for Home Assistant to support Yamaha AV receivers with the YNCA protocol (serial and network). -According to reports of users and info found on the internet the following AV receivers should be working, there are probably more, just give it a try. If your receiver works and is not in the list please post a message in the [discussions](https://github.com/mvdwetering/yamaha_ynca/discussions) so the list can be updated. +According to reports of users and info found on the internet the following AV receivers should be working. There are probably more receivers that work, just give it a try. If your receiver works and is not in the list, please post a message in the [discussions](https://github.com/mvdwetering/yamaha_ynca/discussions) so the list can be updated. > HTR-4065, HTR-4071, HTR-6064, RX-A2A, RX-A6A, RX-A660, RX-A700, RX-A710, RX-A720, RX-A740, RX-A750, RX-A800, RX-A810, RX-A820, RX-A830, RX-A840, RX-A850, RX-A870, RX-A1000, RX-A1010, RX-A1020, RX-A1030, RX-A1040, RX-A2000, RX-A2010, RX-A2020, RX-A2070, RX-A3000, RX-A3010, RX-A3020, RX-A3030, RX-A3070, RX-S600D, RX-V475, RX-V477, RX-V481D, RX-V483, RX-V500D, RX-V575, RX-V671, RX-V673, RX-V675, RX-V677, RX-V679, RX-V681, RX-V685, RX-V771, RX-V773, RX-V775, RX-V777, RX-V867, RX-V871, RX-V1067, RX-V1071, RX-V1085, RX-V2067, RX-V2071, RX-V3067, RX-V3071, TSR-700, TSR-7850 @@ -28,9 +28,9 @@ In case of issues or feature requests please [submit an issue on Github](https:/ * Volume control and mute * Source selection * Soundmode selection -* Show metadata like artist, album, song (depends on source) -* Control playback (depends on source) -* Activate scenes +* Control playback state (depends on source) +* Provide metadata like artist, album, song (depends on source) +* Activate scenes (like the buttons on the front) * [Presets](#presets) * Send [remote control commands](#remote-control) * Several controllable settings (if supported by receiver): @@ -56,24 +56,24 @@ HACS is a 3rd party downloader for Home Assistant to easily install and update c * 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)". You might need to clear the browser cache for it to show up (e.g. reload with CTRL+F5). +* Go to the Home Assistant integrations menu, 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 the zipfile from the releases. +* Download the zipfile from the releases. * Extract the zip and copy the contents to the `custom_components` directory. * Restart Home Assistant -* 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). +* Go to the Home Assistant integrations menu, 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). ## Volume (dB) entity -The volume of a `media_player` entity in Home Assistant has to be in the range 0-to-1. The range of a Yamaha receiver is typically -80.5dB to 16.5dB and is shown in dB unit on the display. This integration maps the full dB range onto the 0-to-1 range in Home Assistant. But this makes setting volume in Home Assistant difficult as those Home Assistant numbers are not easily convertible to the dB numbers shown by the receiver. +The volume of a `media_player` entity in Home Assistant has to be in the range 0-to-1 (shown as 0-100% in the dashboard). The range of a Yamaha receiver is typically -80.5dB to 16.5dB and is shown in the dB unit on the display/overlay. To provide the full volume range to Home Assistant this integration maps the full dB range onto the 0-to-1 range in Home Assistant. However, this makes controlling volume in Home Assistant difficult as the Home Assistant numbers are not easily convertible to the dB numbers as shown by the receiver. -The "Volume (dB)" entity was added to work around this. It is basically the same as the `media_player` volume, but using the familiar dB values that the receiver shows. +The "Volume (dB)" entity was added to simplify this. It is a number entity that controls the volume of a zone, but using the familiar dB unit. ## Remote control -The remote control entity allows sending remote control codes and commands to the receiver. There is remote entity for each zone. Some remote commands are forwarded through HDMI-CEC and can be used to control other devices that way. I guess the commands are also sent over the remote out of the receiver, but that needs to be validated by someone that has equipment connected to the remote out port. +The remote control entity allows sending remote control codes and commands to the receiver. There is remote entity for each zone. The current list of commands is below. For the list of supported commands for a specific entity check the "commands" attribute of the remote entity. Note that this command list does not take zone capabilities into account, just that there is a known remote control code for that command. @@ -98,12 +98,12 @@ target: In case you want to have buttons on a dashboard to send the commands the code below can be used as a starting point. It uses only standard built-in Home Assistant cards, so it should work on all configurations. -On a dashboard add a "manual" card. Paste the code below and search and replace the `entity_id` with your own. +![image](https://github.com/mvdwetering/yamaha_ynca/assets/732514/321181e2-81c3-4a1d-8084-8efceb94f7ff)
-Grid with buttons for remote control commands. +Code for the grid with buttons for remote control commands. -![image](https://github.com/mvdwetering/yamaha_ynca/assets/732514/321181e2-81c3-4a1d-8084-8efceb94f7ff) +On a dashboard, add a "manual" card. Paste the code below and search and replace the `entity_id` with your own. ```yaml type: vertical-stack @@ -518,14 +518,16 @@ cards: ## Presets -Presets can be activated and stored with the integration on many inputs. The most obvious inputs that support presets are the radio inputs like AM/FM tuner. Due to limitations on the protocol the integration can only show the preset number, no name or what is stored. Inputs that support presets are: Napster, Netradio, Pandora, PC, Rhapsody, Sirius, SiriusIR, Tuner and USB. +Presets can be activated and stored with the integration for several inputsources. The most obvious input that support presets is the radio inputs like AM/FM or DAB tuner. Inputs that support presets are: Napster, Netradio, Pandora, PC, Rhapsody, Sirius, SiriusIR, Tuner and USB. + +Presets can be selected in the mediabrowser of the mediaplayer or in automations with the `media_player.play_media` service. When selecting a preset, the receiver will turn on and switch input if needed. -Presets can be selected in the mediabrowser of the mediaplayer or in automations with the `media_player.play_media` service. When selecting a preset the receiver will turn on and switch input if needed. +Due to limitations on the protocol the integration can only show the preset number, no name or what is stored. ### Store presets -Some presets can be managed in the Yamaha AV Control app (e.g. Tuner). -Home Assistant has no standardized way to store presets, so the `store_preset` service was added. It will store a preset with the provided number for the current playing item. +Some presets can be managed in the Yamaha AV Control app (e.g. Tuner presets). +Home Assistant has no standardized way to manage presets, so the `store_preset` service was added. It will store a preset with the provided number for the current playing item. ```yaml service: yamaha_ynca.store_preset @@ -562,7 +564,7 @@ The `media_content_type` is always "music". The `media_content_id` format is lis The receiver does not allow changing of settings when it is in standby, so the entities become Unavailable in Home Assistant to indicate this. * **Q: Why does the integration shows too many or not enough features that are available on my receiver?** - The integration tries to autodetect as many features as possible, but it is not possible for all features on all receivers. For example, supported soundmodes, available inputs, scenes or surround decoders cannot always be detected. You can adjust these features for your receiver in the integration configuration. + The integration tries to autodetect as many features as possible, but it is not possible for all features on all receivers. You can adjust detected/supported features for your receiver in the integration configuration. * **Q: Why are Scene buttons are not working on some receivers?** On some receivers (e.g. RX-V475 with firmware 1.34/2.06) the command to activate the scenes does not work even though the receiver indicates support for them. There might be more receivers with this issue, please report them in an issue or start a discussion. From 5770b3d24adf342fa8d97ffe0d5e2d36d04a32ae Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Fri, 26 Jul 2024 21:45:06 +0200 Subject: [PATCH 15/16] Update README.md (#313) --- README.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f802dd6..5464e9c 100644 --- a/README.md +++ b/README.md @@ -566,6 +566,9 @@ The `media_content_type` is always "music". The `media_content_id` format is lis * **Q: Why does the integration shows too many or not enough features that are available on my receiver?** The integration tries to autodetect as many features as possible, but it is not possible for all features on all receivers. You can adjust detected/supported features for your receiver in the integration configuration. +* **Q: How can I stream audio from a URL?** + You can't with this integration since the protocol does not support that. You might be able to use the [DLNA Digital Media Renderer integration](https://www.home-assistant.io/integrations/dlna_dmr/) that comes with Home Assistant. + * **Q: Why are Scene buttons are not working on some receivers?** On some receivers (e.g. RX-V475 with firmware 1.34/2.06) the command to activate the scenes does not work even though the receiver indicates support for them. There might be more receivers with this issue, please report them in an issue or start a discussion. @@ -580,7 +583,3 @@ data: target: entity_id: remote.rx_V475_main_remote ``` - -* **Q: How can I stream audio from a URL?** - You can't with this integration since the protocol does not support that. You might be able to use the [DLNA Digital Media Renderer integration](https://www.home-assistant.io/integrations/dlna_dmr/) that comes with Home Assistant. - From d7fd1b92d6c0299096b9af8477f696ad2ecd597c Mon Sep 17 00:00:00 2001 From: Michel van de Wetering Date: Sat, 27 Jul 2024 12:40:28 +0200 Subject: [PATCH 16/16] Update version to 7.10.0 --- custom_components/yamaha_ynca/manifest.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/yamaha_ynca/manifest.json b/custom_components/yamaha_ynca/manifest.json index 83a5f00..de6128b 100644 --- a/custom_components/yamaha_ynca/manifest.json +++ b/custom_components/yamaha_ynca/manifest.json @@ -1,7 +1,6 @@ { "domain": "yamaha_ynca", "name": "Yamaha (YNCA)", - "codeowners": [ "@mvdwetering" ], @@ -10,9 +9,11 @@ "documentation": "https://github.com/mvdwetering/yamaha_ynca", "iot_class": "local_push", "issue_tracker": "https://github.com/mvdwetering/yamaha_ynca/issues", - "loggers": ["ynca"], + "loggers": [ + "ynca" + ], "requirements": [ "ynca==5.16.0" ], - "version": "0.0.0" + "version": "7.10.0" }