diff --git a/.github/workflows/validations.yaml b/.github/workflows/validations.yaml index 0ac9c65..e2aa357 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 }} @@ -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/README.md b/README.md index 7f794cf..5464e9c 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,17 +518,16 @@ 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 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 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. -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 @@ -540,7 +539,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. @@ -565,16 +564,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. You can adjust detected/supported 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: 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. - As an alternative the scenes can be activated by sending the scene commands through the [Remote control entity](#remote-control). +* **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. -* **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). + 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 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. + 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 +``` 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 dca6244..c3aeabf 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 ): @@ -106,16 +109,37 @@ 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) + + +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: + +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): @@ -164,44 +188,32 @@ 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 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) - - 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 - ) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_options)) 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) - hass.services.async_remove(DOMAIN, SERVICE_SEND_RAW_YNCA) - 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/config_flow.py b/custom_components/yamaha_ynca/config_flow.py index c95d87a..40037fa 100644 --- a/custom_components/yamaha_ynca/config_flow.py +++ b/custom_components/yamaha_ynca/config_flow.py @@ -1,20 +1,21 @@ """Config flow for Yamaha (YNCA) integration.""" + from __future__ import annotations from typing import Any, Dict +import re 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 ConfigFlow, ConfigFlowResult +from homeassistant.core import HomeAssistant, callback +from . import YamahaYncaConfigEntry from .const import ( - CONF_SERIAL_URL, CONF_HOST, CONF_PORT, + CONF_SERIAL_URL, DATA_MODELNAME, DATA_ZONES, DOMAIN, @@ -22,9 +23,6 @@ ) from .options_flow import OptionsFlowHandler -import ynca - - STEP_ID_SERIAL = "serial" STEP_ID_NETWORK = "network" STEP_ID_ADVANCED = "advanced" @@ -69,15 +67,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 - + reconfigure_entry: YamahaYncaConfigEntry | None = None @staticmethod @callback @@ -86,7 +83,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 +95,7 @@ async def async_try_connect( step_id: str, data_schema: vol.Schema, user_input: Dict[str, Any], - ) -> FlowResult: + ) -> ConfigFlowResult: errors = {} try: @@ -111,15 +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 - - if self.reauth_entry: - 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") + data = { + CONF_SERIAL_URL: user_input[CONF_SERIAL_URL], + DATA_MODELNAME: check_result.modelname, + DATA_ZONES: check_result.zones, + } + + if self.reconfigure_entry: + self.hass.config_entries.async_update_entry( + self.reconfigure_entry, data=data + ) + await self.hass.config_entries.async_reload( + self.reconfigure_entry.entry_id + ) + return self.async_abort(reason="reconfigure_successful") return self.async_create_entry(title=check_result.modelname, data=data) @@ -131,10 +133,18 @@ 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({}) + 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( @@ -143,10 +153,20 @@ 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: + 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,19 +178,23 @@ 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({}) + 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/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/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/manifest.json b/custom_components/yamaha_ynca/manifest.json index 02bee98..de6128b 100644 --- a/custom_components/yamaha_ynca/manifest.json +++ b/custom_components/yamaha_ynca/manifest.json @@ -13,7 +13,7 @@ "ynca" ], "requirements": [ - "ynca==5.15.0" + "ynca==5.16.0" ], - "version": "7.9.0" + "version": "7.10.0" } 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/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 + ) 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..cbafa69 100644 --- a/custom_components/yamaha_ynca/options_flow.py +++ b/custom_components/yamaha_ynca/options_flow.py @@ -1,16 +1,12 @@ """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 -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, @@ -21,13 +17,11 @@ CONF_SELECTED_SURROUND_DECODERS, DATA_MODELNAME, DATA_ZONES, - DOMAIN, - LOGGER, MAX_NUMBER_OF_SCENES, NUMBER_OF_SCENES_AUTODETECT, TWOCHDECODER_STRINGS, - SURROUNDDECODEROPTIONS_PLIIX_MAPPING, ) +from .input_helpers import InputHelper STEP_ID_INIT = "init" STEP_ID_NO_CONNECTION = "no_connection" @@ -55,6 +49,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 +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 ( - 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 + # 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() @@ -91,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) @@ -123,7 +119,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 +152,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 +171,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 5147049..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: @@ -116,7 +119,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__( @@ -136,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: """ @@ -157,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 @@ -177,6 +183,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/custom_components/yamaha_ynca/select.py b/custom_components/yamaha_ynca/select.py index 84a5715..6853fb3 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, subunit_supports_entitydescription_key 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: @@ -185,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.""" @@ -196,17 +200,30 @@ 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 entity_description, zone_subunit: ( + subunit_supports_entitydescription_key(entity_description, zone_subunit) + and 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 deleted file mode 100644 index 99ff66f..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 and the port.\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.", - "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_advanced": "Connection failed, check URL handler format.", - "unknown": "Unexpected error." - }, - "abort": { - "already_configured": "Device is already configured", - "reauth_successful": "Successfully reconfigured connection" - } - }, - "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": "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)." - } - }, - "abort": { - "marked_for_reconfiguring": "Configentry is marked for reconfiguration. You might need to refresh the browser." - } - }, - "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" - } - }, - "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/switch.py b/custom_components/yamaha_ynca/switch.py index 0d4eee5..1447452 100644 --- a/custom_components/yamaha_ynca/switch.py +++ b/custom_components/yamaha_ynca/switch.py @@ -1,18 +1,22 @@ 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 -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 . import YamahaYncaConfigEntry +from .const import ZONE_ATTRIBUTE_NAMES +from .helpers import YamahaYncaSettingEntity, subunit_supports_entitydescription_key -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) @@ -27,6 +31,17 @@ 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: 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. + 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 +71,19 @@ 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 entity_description, zone_subunit: ( + subunit_supports_entitydescription_key(entity_description, zone_subunit) + and zone_subunit.lipsynchdmiout2offset is None + ), + ), ] SYS_ENTITY_DESCRIPTIONS = [ @@ -79,15 +107,19 @@ class YncaSwitchEntityDescription(SwitchEntityDescription): ), ] -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): 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 @@ -95,16 +127,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/custom_components/yamaha_ynca/translations/en.json b/custom_components/yamaha_ynca/translations/en.json index 99ff66f..9fe1ba0 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 integration without active connection to the receiver.\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": { @@ -150,6 +147,9 @@ }, "threedcinema": { "name": "CINEMA DSP 3D Mode" + }, + "hdmiout": { + "name": "HDMI Out" } }, "select": { 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.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/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/conftest.py b/tests/conftest.py index e556d1a..34f4f66 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,14 +3,13 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Callable, NamedTuple, Type -from unittest.mock import DEFAULT, Mock, create_autospec, patch +from typing import Callable, Generator, NamedTuple, Type +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, @@ -49,6 +48,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(): @@ -96,6 +104,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 @@ -181,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] @@ -197,7 +207,6 @@ async def setup_integration( mock_ynca: ynca.YncaApi, skip_setup=False, serial_url="SerialUrl", - enable_all_entities=False, ): zones = [] if mock_ynca.main: @@ -212,25 +221,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_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_init.py b/tests/test_init.py index 7f7f090..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") @@ -209,6 +204,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) 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_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) diff --git a/tests/test_options_flow.py b/tests/test_options_flow.py index 4359ef8..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) @@ -174,7 +173,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) @@ -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 c582b42..80f4c7e 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 @@ -42,7 +43,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 @@ -232,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, @@ -246,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 8a99a6f..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", ) @@ -41,6 +40,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 +63,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): @@ -92,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"] == { @@ -110,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