From ad6642659e7cf9a229e3f0b73cd13ef956624ac2 Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 22 Nov 2024 10:27:41 +0100 Subject: [PATCH 01/14] #130820 Add config flow to Yamaha --- homeassistant/components/yamaha/__init__.py | 77 ++++ .../components/yamaha/config_flow.py | 240 ++++++++++++ homeassistant/components/yamaha/const.py | 8 +- homeassistant/components/yamaha/manifest.json | 12 +- .../components/yamaha/media_player.py | 175 +++------ homeassistant/components/yamaha/strings.json | 35 ++ .../components/yamaha/yamaha_config_info.py | 32 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- homeassistant/generated/ssdp.py | 8 + tests/components/yamaha/test_config_flow.py | 344 ++++++++++++++++++ tests/components/yamaha/test_media_player.py | 34 +- 12 files changed, 830 insertions(+), 138 deletions(-) create mode 100644 homeassistant/components/yamaha/config_flow.py create mode 100644 homeassistant/components/yamaha/yamaha_config_info.py create mode 100644 tests/components/yamaha/test_config_flow.py diff --git a/homeassistant/components/yamaha/__init__.py b/homeassistant/components/yamaha/__init__.py index 92a34517ec6eae..31db464c6864eb 100644 --- a/homeassistant/components/yamaha/__init__.py +++ b/homeassistant/components/yamaha/__init__.py @@ -1 +1,78 @@ """The yamaha component.""" + +from __future__ import annotations + +import logging + +import rxv + +from homeassistant.components import ssdp +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_MODEL, CONF_SERIAL, DOMAIN +from .yamaha_config_info import YamahaConfigInfo + +PLATFORMS = [Platform.MEDIA_PLAYER] + +_LOGGER = logging.getLogger(__name__) + + +async def get_upnp_serial_and_model(hass: HomeAssistant, host: str): + """Get the upnp serial and model for a given host, using the SSPD scanner.""" + ssdp_entries = await ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") + matches = [w for w in ssdp_entries if w.ssdp_headers.get("_host", "") == host] + upnp_serial = None + model = None + for match in matches: + if match.ssdp_location: + upnp_serial = match.upnp[ssdp.ATTR_UPNP_SERIAL] + model = match.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + break + + if upnp_serial is None: + _LOGGER.warning( + "Could not find serial from SSDP, attempting to retrieve serial from SSDP description URL" + ) + upnp_serial, model = await YamahaConfigInfo.get_upnp_serial_and_model(host, async_get_clientsession(hass)) + return upnp_serial, model + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Yamaha from a config entry.""" + if entry.data.get(CONF_NAME) is None: + upnp, model = await get_upnp_serial_and_model(hass, entry.data[CONF_HOST]) + hass.config_entries.async_update_entry( + entry, + data={ + CONF_HOST: entry.data[CONF_HOST], + CONF_SERIAL: entry.data[CONF_SERIAL], + CONF_NAME: upnp[ssdp.ATTR_UPNP_MODEL_NAME], + CONF_MODEL: entry.data[CONF_MODEL], + }, + ) + + hass.data.setdefault(DOMAIN, {}) + info = YamahaConfigInfo(entry.data[CONF_HOST]) + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( rxv.RXV, info.ctrl_url, entry.data[CONF_MODEL], entry.data[CONF_SERIAL] ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py new file mode 100644 index 00000000000000..24b52a3eff5376 --- /dev/null +++ b/homeassistant/components/yamaha/config_flow.py @@ -0,0 +1,240 @@ +"""Config flow for Yamaha.""" + +from __future__ import annotations + +import logging +from typing import Any +from urllib.parse import urlparse + +from requests.exceptions import ConnectionError +import rxv +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components import ssdp +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlow, +) +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + Selector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from . import get_upnp_serial_and_model +from .const import ( + CONF_MODEL, + CONF_SERIAL, + DEFAULT_NAME, + DOMAIN, + OPTION_INPUT_SOURCES, + OPTION_INPUT_SOURCES_IGNORE, +) +from .yamaha_config_info import YamahaConfigInfo + +_LOGGER = logging.getLogger(__name__) + + +class YamahaFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a Yamaha config flow.""" + + VERSION = 1 + + serial_number: str | None = None + model: str | None = None + host: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> data_entry_flow.ConfigFlowResult: + """Handle a flow initiated by the user.""" + # Request user input, unless we are preparing discovery flow + if user_input is None: + return self._show_setup_form() + + host = user_input[CONF_HOST] + serial_number = None + model = None + + errors = {} + # Check if device is a Yamaha receiver + try: + info = YamahaConfigInfo(host) + await self.hass.async_add_executor_job( rxv.RXV, info.ctrl_url ) + serial_number, model = await get_upnp_serial_and_model(self.hass, host) + except ConnectionError: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if serial_number is None: + errors["base"] = "no_yamaha_device" + + if not errors: + await self.async_set_unique_id(serial_number, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=model, + data={ + CONF_HOST: host, + CONF_MODEL: model, + CONF_SERIAL: serial_number, + CONF_NAME: user_input.get(CONF_NAME) or DEFAULT_NAME, + }, + options={ + OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) or [], + OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, + }, + ) + + return self._show_setup_form(errors) + + def _show_setup_form( + self, errors: dict | None = None + ) -> data_entry_flow.ConfigFlowResult: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors or {}, + ) + + async def async_step_ssdp( + self, discovery_info: ssdp.SsdpServiceInfo + ) -> data_entry_flow.ConfigFlowResult: + """Handle ssdp discoveries.""" + if not await YamahaConfigInfo.check_yamaha_ssdp( + discovery_info.ssdp_location, async_get_clientsession(self.hass) + ): + return self.async_abort(reason="yxc_control_url_missing") + self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] + self.model = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + + # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment + self.host = urlparse(discovery_info.ssdp_location).hostname # type: ignore[assignment] + + await self.async_set_unique_id(self.serial_number) + self._abort_if_unique_id_configured( + { + CONF_HOST: self.host, + CONF_NAME: self.model, + } + ) + self.context.update( + { + "title_placeholders": { + "name": discovery_info.upnp.get( + ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host + ) + } + } + ) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input=None + ) -> data_entry_flow.ConfigFlowResult: + """Allow the user to confirm adding the device.""" + if user_input is not None: + return self.async_create_entry( + title=self.model, + data={ + CONF_HOST: self.host, + CONF_MODEL: self.model, + CONF_SERIAL: self.serial_number, + CONF_NAME: DEFAULT_NAME, + }, + options={ + OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) or [], + OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, + }, + ) + + return self.async_show_form(step_id="confirm") + + async def async_step_import(self, import_data: dict) -> data_entry_flow.FlowResult: + """Import data from configuration.yaml into the config flow.""" + res = await self.async_step_user(import_data) + if res["type"] == FlowResultType.CREATE_ENTRY: + _LOGGER.info( + "Successfully imported %s from configuration.yaml", + import_data.get(CONF_HOST), + ) + elif res["type"] == FlowResultType.FORM: + _LOGGER.error( + "Could not import %s from configuration.yaml", + import_data.get(CONF_HOST), + ) + return res + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> OptionsFlow: + """Return the options flow.""" + return YamahaOptionsFlowHandler(config_entry) + + +class YamahaOptionsFlowHandler(OptionsFlow): + """Handle an options flow for Yamaha.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize options flow.""" + self._input_sources_ignore: list[str] = config_entry.options[OPTION_INPUT_SOURCES_IGNORE] + self._input_sources: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage the options.""" + yamaha = self.hass.data[DOMAIN][self.config_entry.entry_id] + inputs = await self.hass.async_add_executor_job( yamaha.inputs ) + + if user_input is not None: + sources_store: dict[str, str] = {k: v for k, v in user_input.items() if k in inputs and v != ""} + + return self.async_create_entry( + data={ + OPTION_INPUT_SOURCES: sources_store, + OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) + } + ) + + schema_dict: dict[Any, Selector] = {} + available_inputs = [ SelectOptionDict(value=k, label=k) for k, v in inputs.items() ] + + schema_dict[vol.Optional(OPTION_INPUT_SOURCES_IGNORE)] = SelectSelector( + SelectSelectorConfig(options=available_inputs, mode=SelectSelectorMode.DROPDOWN, multiple=True) + ) + + for source in inputs: + if source not in self._input_sources_ignore: + schema_dict[vol.Optional(source, default="")] = ( + TextSelector() + ) + + options = self.config_entry.options.copy() + if OPTION_INPUT_SOURCES_IGNORE in self.config_entry.options: + options[OPTION_INPUT_SOURCES_IGNORE] = self.config_entry.options[OPTION_INPUT_SOURCES_IGNORE] + if OPTION_INPUT_SOURCES in self.config_entry.options: + for source, source_name in self.config_entry.options[OPTION_INPUT_SOURCES].items(): + options[source] = source_name + + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( vol.Schema( schema_dict ), options ), + ) diff --git a/homeassistant/components/yamaha/const.py b/homeassistant/components/yamaha/const.py index 1cdb619b6ef4e4..71a9bef5fa868d 100644 --- a/homeassistant/components/yamaha/const.py +++ b/homeassistant/components/yamaha/const.py @@ -1,8 +1,9 @@ """Constants for the Yamaha component.""" DOMAIN = "yamaha" -DISCOVER_TIMEOUT = 3 -KNOWN_ZONES = "known_zones" +BRAND = "Yamaha Corporation" +CONF_SERIAL = "serial" +CONF_MODEL = "model" CURSOR_TYPE_DOWN = "down" CURSOR_TYPE_LEFT = "left" CURSOR_TYPE_RETURN = "return" @@ -12,3 +13,6 @@ SERVICE_ENABLE_OUTPUT = "enable_output" SERVICE_MENU_CURSOR = "menu_cursor" SERVICE_SELECT_SCENE = "select_scene" +OPTION_INPUT_SOURCES = "source_names" +OPTION_INPUT_SOURCES_IGNORE = "source_ignore" +DEFAULT_NAME = "Yamaha Receiver" diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json index 8e6ba0b8854c08..d4683024b41276 100644 --- a/homeassistant/components/yamaha/manifest.json +++ b/homeassistant/components/yamaha/manifest.json @@ -2,8 +2,18 @@ "domain": "yamaha", "name": "Yamaha Network Receivers", "codeowners": [], + "config_flow": true, + "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/yamaha", "iot_class": "local_polling", "loggers": ["rxv"], - "requirements": ["rxv==0.7.0"] + "requirements": ["rxv==0.7.0"], + "ssdp": [ + { + "manufacturer": "YAMAHA CORPORATION" + }, + { + "manufacturer": "Yamaha Corporation" + } + ] } diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index c16433b3c378c3..65a0232e568fb1 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -11,29 +11,31 @@ import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, MediaType, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( + BRAND, CURSOR_TYPE_DOWN, CURSOR_TYPE_LEFT, CURSOR_TYPE_RETURN, CURSOR_TYPE_RIGHT, CURSOR_TYPE_SELECT, CURSOR_TYPE_UP, - DISCOVER_TIMEOUT, DOMAIN, - KNOWN_ZONES, + OPTION_INPUT_SOURCES, + OPTION_INPUT_SOURCES_IGNORE, SERVICE_ENABLE_OUTPUT, SERVICE_MENU_CURSOR, SERVICE_SELECT_SCENE, @@ -47,11 +49,6 @@ ATTR_SCENE = "scene" -CONF_SOURCE_IGNORE = "source_ignore" -CONF_SOURCE_NAMES = "source_names" -CONF_ZONE_IGNORE = "zone_ignore" -CONF_ZONE_NAMES = "zone_names" - CURSOR_TYPE_MAP = { CURSOR_TYPE_DOWN: rxv.RXV.menu_down.__name__, CURSOR_TYPE_LEFT: rxv.RXV.menu_left.__name__, @@ -60,7 +57,6 @@ CURSOR_TYPE_SELECT: rxv.RXV.menu_sel.__name__, CURSOR_TYPE_UP: rxv.RXV.menu_up.__name__, } -DEFAULT_NAME = "Yamaha Receiver" SUPPORT_YAMAHA = ( MediaPlayerEntityFeature.VOLUME_SET @@ -72,117 +68,61 @@ | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) -PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_SOURCE_IGNORE, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_ZONE_IGNORE, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string}, - vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string}, - } -) +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Yamaha zones based on a config entry.""" + device: rxv.RXV = hass.data[DOMAIN][entry.entry_id] -class YamahaConfigInfo: - """Configuration Info for Yamaha Receivers.""" + media_players: list[Entity] = [] - def __init__( - self, config: ConfigType, discovery_info: DiscoveryInfoType | None - ) -> None: - """Initialize the Configuration Info for Yamaha Receiver.""" - self.name = config.get(CONF_NAME) - self.host = config.get(CONF_HOST) - self.ctrl_url: str | None = f"http://{self.host}:80/YamahaRemoteControl/ctrl" - self.source_ignore = config.get(CONF_SOURCE_IGNORE) - self.source_names = config.get(CONF_SOURCE_NAMES) - self.zone_ignore = config.get(CONF_ZONE_IGNORE) - self.zone_names = config.get(CONF_ZONE_NAMES) - self.from_discovery = False - _LOGGER.debug("Discovery Info: %s", discovery_info) - if discovery_info is not None: - self.name = discovery_info.get("name") - self.model = discovery_info.get("model_name") - self.ctrl_url = discovery_info.get("control_url") - self.desc_url = discovery_info.get("description_url") - self.zone_ignore = [] - self.from_discovery = True - - -def _discovery(config_info: YamahaConfigInfo) -> list[RXV]: - """Discover list of zone controllers from configuration in the network.""" - if config_info.from_discovery: - _LOGGER.debug("Discovery Zones") - zones = rxv.RXV( - config_info.ctrl_url, - model_name=config_info.model, - friendly_name=config_info.name, - unit_desc_url=config_info.desc_url, - ).zone_controllers() - elif config_info.host is None: - _LOGGER.debug("Config No Host Supplied Zones") - zones = [] - for recv in rxv.find(DISCOVER_TIMEOUT): - zones.extend(recv.zone_controllers()) - else: - _LOGGER.debug("Config Zones") - zones = rxv.RXV(config_info.ctrl_url, config_info.name).zone_controllers() + for zctrl in device.zone_controllers(): + entity = YamahaDeviceZone( + entry.data.get(CONF_NAME), + zctrl, + entry.options.get(OPTION_INPUT_SOURCES_IGNORE), + entry.options.get(OPTION_INPUT_SOURCES), + ) + + media_players.append(entity) - _LOGGER.debug("Returned _discover zones: %s", zones) - return zones + async_add_entities(media_players) async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Yamaha platform.""" - # Keep track of configured receivers so that we don't end up - # discovering a receiver dynamically that we have static config - # for. Map each device from its zone_id . - known_zones = hass.data.setdefault(DOMAIN, {KNOWN_ZONES: set()})[KNOWN_ZONES] - _LOGGER.debug("Known receiver zones: %s", known_zones) - - # Get the Infos for configuration from config (YAML) or Discovery - config_info = YamahaConfigInfo(config=config, discovery_info=discovery_info) - # Async check if the Receivers are there in the network - try: - zone_ctrls = await hass.async_add_executor_job(_discovery, config_info) - except requests.exceptions.ConnectionError as ex: - raise PlatformNotReady(f"Issue while connecting to {config_info.name}") from ex - - entities = [] - for zctrl in zone_ctrls: - _LOGGER.debug("Receiver zone: %s serial %s", zctrl.zone, zctrl.serial_number) - if config_info.zone_ignore and zctrl.zone in config_info.zone_ignore: - _LOGGER.debug("Ignore receiver zone: %s %s", config_info.name, zctrl.zone) - continue - - assert config_info.name - entity = YamahaDeviceZone( - config_info.name, - zctrl, - config_info.source_ignore, - config_info.source_names, - config_info.zone_names, - ) - - # Only add device if it's not already added - if entity.zone_id not in known_zones: - known_zones.add(entity.zone_id) - entities.append(entity) + if config.get(CONF_HOST): + if hass.config_entries.async_entries(DOMAIN) and config[CONF_HOST] not in [ + entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) + ]: + _LOGGER.error( + "Configuration in configuration.yaml is not supported anymore. " + "Please add this device using the config flow: %s.", + config[CONF_HOST], + ) else: - _LOGGER.debug( - "Ignoring duplicate zone: %s %s", config_info.name, zctrl.zone + _LOGGER.warning( + "Configuration in configuration.yaml is deprecated. Use the config flow instead." ) - async_add_entities(entities) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + else: + _LOGGER.error( + "Configuration in configuration.yaml is not supported anymore. " + "Please add this device using the config flow." + ) # Register Service 'select_scene' platform = entity_platform.async_get_current_platform() @@ -216,7 +156,6 @@ def __init__( zctrl: RXV, source_ignore: list[str] | None, source_names: dict[str, str] | None, - zone_names: dict[str, str] | None, ) -> None: """Initialize the Yamaha Receiver.""" self.zctrl = zctrl @@ -225,7 +164,6 @@ def __init__( self._attr_state = MediaPlayerState.OFF self._source_ignore: list[str] = source_ignore or [] self._source_names: dict[str, str] = source_names or {} - self._zone_names: dict[str, str] = zone_names or {} self._playback_support = None self._is_playback_supported = False self._play_status = None @@ -236,6 +174,12 @@ def __init__( # the default name of the integration may not be changed # to avoid a breaking change. self._attr_unique_id = f"{self.zctrl.serial_number}_{self._zone}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=BRAND, + name=name + " " + zctrl.zone, + model=zctrl.model_name + ) def update(self) -> None: """Get the latest details from the device.""" @@ -293,10 +237,9 @@ def build_source_list(self) -> None: def name(self) -> str: """Return the name of the device.""" name = self._name - zone_name = self._zone_names.get(self._zone, self._zone) - if zone_name != "Main_Zone": + if self._zone != "Main_Zone": # Zone will be one of Main_Zone, Zone_2, Zone_3 - name += f" {zone_name.replace('_', ' ')}" + name += f" {self._zone.replace('_', ' ')}" return name @property @@ -312,7 +255,7 @@ def supported_features(self) -> MediaPlayerEntityFeature: supports = self._playback_support mapping = { "play": ( - MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA ), "pause": MediaPlayerEntityFeature.PAUSE, "stop": MediaPlayerEntityFeature.STOP, @@ -374,7 +317,7 @@ def select_source(self, source: str) -> None: self.zctrl.input = self._reverse_mapping.get(source, source) def play_media( - self, media_type: MediaType | str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media from an ID. diff --git a/homeassistant/components/yamaha/strings.json b/homeassistant/components/yamaha/strings.json index ecb69d9fc38d2f..720b1fb665e81d 100644 --- a/homeassistant/components/yamaha/strings.json +++ b/homeassistant/components/yamaha/strings.json @@ -1,4 +1,39 @@ { + "config": { + "flow_title": "Receiver: {name}", + "step": { + "user": { + "description": "Set up Yamaha to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" + }, + "data_description": { + "host": "Hostname or IP address of your Yamaha receiver." + } + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "yxc_control_url_missing": "The control URL is not given in the ssdp description." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_yamaha_device": "This device seems to be no Yamaha Device.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "source_ignore": "Ignore input sources" + } + } + } + }, "services": { "enable_output": { "name": "Enable output", diff --git a/homeassistant/components/yamaha/yamaha_config_info.py b/homeassistant/components/yamaha/yamaha_config_info.py new file mode 100644 index 00000000000000..2669cda05ef46a --- /dev/null +++ b/homeassistant/components/yamaha/yamaha_config_info.py @@ -0,0 +1,32 @@ +"""Configuration Information for Yamaha.""" + +from aiohttp import ClientSession +import defusedxml.ElementTree as ET + +ns = {"s": "urn:schemas-upnp-org:device-1-0"} + + +class YamahaConfigInfo: + """Configuration Info for Yamaha Receivers.""" + + def __init__( + self, host: str + ) -> None: + """Initialize the Configuration Info for Yamaha Receiver.""" + self.ctrl_url: str | None = f"http://{host}:80/YamahaRemoteControl/ctrl" + + @classmethod + async def check_yamaha_ssdp(cls, location: str, client: ClientSession): + """Check if the Yamaha receiver has a valid control URL.""" + res = await client.get(location) + text = await res.text() + return text.find('/YamahaRemoteControl/ctrl') != -1 + + @classmethod + async def get_upnp_serial_and_model(cls, host : str, client: ClientSession): + """Retrieve the serial_number and model from the SSDP description URL.""" + res = await client.get(f"http://{host}:49154/MediaRenderer/desc.xml" ) + root = ET.fromstring(await res.text()) + serial_number = root.find("./s:device/s:serialNumber", ns).text + model_name = root.find("./s:device/s:modelName", ns).text + return serial_number, model_name diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ffe61b915c648d..2b2cba5bc0001b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -693,6 +693,7 @@ "yale", "yale_smart_alarm", "yalexs_ble", + "yamaha", "yamaha_musiccast", "yardian", "yeelight", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index f007db878686de..22eb86667a8dda 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7152,7 +7152,7 @@ "integrations": { "yamaha": { "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_polling", "name": "Yamaha Network Receivers" }, diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 9ed65bab868c9f..e4b4c13b36c8fa 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -349,6 +349,14 @@ "manufacturer": "All Automacao Ltda", }, ], + "yamaha": [ + { + "manufacturer": "YAMAHA CORPORATION", + }, + { + "manufacturer": "Yamaha Corporation", + }, + ], "yamaha_musiccast": [ { "manufacturer": "Yamaha Corporation", diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py new file mode 100644 index 00000000000000..81a9352477c838 --- /dev/null +++ b/tests/components/yamaha/test_config_flow.py @@ -0,0 +1,344 @@ +"""Test config flow.""" + +from collections.abc import Generator +from unittest.mock import Mock, patch + +import pytest +from requests.exceptions import ConnectionError + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.components.yamaha.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.fixture(autouse=True) +def silent_ssdp_scanner() -> Generator[None]: + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with ( + patch("homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"), + patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), + patch("homeassistant.components.ssdp.Scanner.async_scan"), + patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers", + ), + patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", + ), + ): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_entry(): + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.yamaha.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture +def mock_get_device_info_valid(): + """Mock getting valid device info from Yamaha API.""" + with ( + patch( + "rxv.RXV", + return_value=Mock() + ) + ): + yield + + +@pytest.fixture +def mock_get_device_info_invalid(): + """Mock getting invalid device info from Yamaha API.""" + with ( + patch("rxv.RXV", return_value=None), + patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=(None, None)) + ) : + yield + + +@pytest.fixture +def mock_get_device_info_mc_exception(): + """Mock raising an unexpected Exception.""" + with patch( + "rxv.RXV", + side_effect=ConnectionError("mocked error"), + ): + yield + + +@pytest.fixture +def mock_ssdp_yamaha(): + """Mock that the SSDP detected device is a Yamaha device.""" + with patch("homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", return_value=True): + yield + + +@pytest.fixture +def mock_ssdp_no_yamaha(): + """Mock that the SSDP detected device is not a Yamaha device.""" + with patch("homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", return_value=False): + yield + + +@pytest.fixture +def mock_valid_discovery_information(): + """Mock that the ssdp scanner returns a useful upnp description.""" + with patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[ + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", + ssdp_headers={ + "_host": "127.0.0.1", + }, + upnp={ + ssdp.ATTR_UPNP_SERIAL: "1234567890", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + }, + ) + ], + ): + yield + + +@pytest.fixture +def mock_empty_discovery_information(): + """Mock that the ssdp scanner returns no upnp description.""" + with ( + patch("homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[]), + patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=("1234567890","MC20")) + ): + yield + + +# User Flows + + +async def test_user_input_device_not_found( + hass: HomeAssistant, mock_get_device_info_mc_exception +) -> None: + """Test when user specifies a non-existing device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "none"}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_user_input_non_yamaha_device_found( + hass: HomeAssistant, mock_get_device_info_invalid +) -> None: + """Test when user specifies an existing device, which does not provide the Yamaha API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "no_yamaha_device"} + + +async def test_user_input_device_already_existing( + hass: HomeAssistant, mock_get_device_info_valid, mock_valid_discovery_information +) -> None: + """Test when user specifies an existing device.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data={CONF_HOST: "127.0.0.1", "model": "MC20", "serial": "1234567890"}, + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "already_configured" + + +async def test_user_input_unknown_error( + hass: HomeAssistant +) -> None: + """Test when user specifies an existing device, which does not provide the Yamaha API.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_user_input_device_found( + hass: HomeAssistant, + mock_get_device_info_valid, + mock_valid_discovery_information, +) -> None: + """Test when user specifies an existing device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert isinstance(result2["result"], ConfigEntry) + assert result2["data"] == { + "host": "127.0.0.1", + "model": "MC20", + "serial": "1234567890", + "name": "Yamaha Receiver", + } + + +async def test_user_input_device_found_no_ssdp( + hass: HomeAssistant, + mock_get_device_info_valid, + mock_empty_discovery_information, +) -> None: + """Test when user specifies an existing device, which no discovery data are present for.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert isinstance(result2["result"], ConfigEntry) + assert result2["data"] == { + "host": "127.0.0.1", + "model": "MC20", + "serial": "1234567890", + "name": "Yamaha Receiver", + } + + +# SSDP Flows + + +async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) -> None: + """Test when an SSDP discovered device is not a Yamaha device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1/desc.xml", + upnp={ + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "123456789", + }, + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "yxc_control_url_missing" + + +async def test_ssdp_discovery_successful_add_device( + hass: HomeAssistant, mock_ssdp_yamaha +) -> None: + """Test when the SSDP discovered device is a musiccast device and the user confirms it.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1/desc.xml", + upnp={ + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "1234567890", + }, + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert isinstance(result2["result"], ConfigEntry) + assert result2["data"] == { + "host": "127.0.0.1", + "model": "MC20", + "serial": "1234567890", + "name": "Yamaha Receiver", + } + + +async def test_ssdp_discovery_existing_device_update( + hass: HomeAssistant, mock_ssdp_yamaha +) -> None: + """Test when the SSDP discovered device is a Yamaha device, but it already exists with another IP.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id="1234567890", + data={CONF_HOST: "192.168.188.18", "model": "MC20", "serial": "1234567890"}, + ) + mock_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1/desc.xml", + upnp={ + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + ssdp.ATTR_UPNP_SERIAL: "1234567890", + }, + ), + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_entry.data[CONF_HOST] == "127.0.0.1" + assert mock_entry.data["model"] == "MC20" diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index 2375e7d07f42bc..5aa514a0f5082c 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -48,7 +48,7 @@ def device_fixture(main_zone): device = FakeYamahaDevice("http://receiver", "Receiver", zones=[main_zone]) with ( patch("rxv.RXV", return_value=device), - patch("rxv.find", return_value=[device]), + patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=("1234567890", "MC20")) ): yield device @@ -61,7 +61,7 @@ def device2_fixture(main_zone): ) with ( patch("rxv.RXV", return_value=device), - patch("rxv.find", return_value=[device]), + patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=("0987654321", "AX100")) ): yield device @@ -76,9 +76,8 @@ async def test_setup_host(hass: HomeAssistant, device, device2, main_zone) -> No assert state is not None assert state.state == "off" - with patch("rxv.find", return_value=[device2]): - assert await async_setup_component(hass, MP_DOMAIN, CONFIG) - await hass.async_block_till_done() + assert await async_setup_component(hass, MP_DOMAIN, CONFIG) + await hass.async_block_till_done() state = hass.states.get("media_player.yamaha_receiver_main_zone") @@ -97,30 +96,28 @@ async def test_setup_host(hass: HomeAssistant, device, device2, main_zone) -> No async def test_setup_find_errors(hass: HomeAssistant, device, main_zone, error) -> None: """Test set up integration encountering an Error.""" - with patch("rxv.find", side_effect=error): - assert await async_setup_component(hass, MP_DOMAIN, CONFIG) - await hass.async_block_till_done() + assert await async_setup_component(hass, MP_DOMAIN, CONFIG) + await hass.async_block_till_done() - state = hass.states.get("media_player.yamaha_receiver_main_zone") + state = hass.states.get("media_player.yamaha_receiver_main_zone") - assert state is not None - assert state.state == "off" + assert state is not None + assert state.state == "off" async def test_setup_no_host(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration without host.""" - with patch("rxv.find", return_value=[device]): - assert await async_setup_component( - hass, MP_DOMAIN, {"media_player": {"platform": "yamaha"}} - ) - await hass.async_block_till_done() + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "yamaha"}} + ) + await hass.async_block_till_done() state = hass.states.get("media_player.yamaha_receiver_main_zone") - assert state is not None - assert state.state == "off" + assert state is None +@pytest.mark.skip(reason="Remove this since it relies on a removed Disovery integration?") async def test_setup_discovery(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration via discovery.""" discovery_info = { @@ -140,6 +137,7 @@ async def test_setup_discovery(hass: HomeAssistant, device, main_zone) -> None: assert state.state == "off" +@pytest.mark.skip(reason="Remove this since zone_ignore and zone_names were removed from configation.yaml?") async def test_setup_zone_ignore(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration without host.""" assert await async_setup_component( From 02e611b6cb10c68f6e2dec7dfdea4d7f22b27165 Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 22 Nov 2024 10:43:28 +0100 Subject: [PATCH 02/14] #130820 Add config flow to Yamaha --- homeassistant/components/yamaha/__init__.py | 8 ++- .../components/yamaha/config_flow.py | 58 +++++++++++++------ .../components/yamaha/media_player.py | 20 +++---- .../components/yamaha/yamaha_config_info.py | 15 +++-- tests/components/yamaha/test_config_flow.py | 46 ++++++++------- tests/components/yamaha/test_media_player.py | 18 ++++-- 6 files changed, 105 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/yamaha/__init__.py b/homeassistant/components/yamaha/__init__.py index 31db464c6864eb..eb517be53dc483 100644 --- a/homeassistant/components/yamaha/__init__.py +++ b/homeassistant/components/yamaha/__init__.py @@ -36,7 +36,9 @@ async def get_upnp_serial_and_model(hass: HomeAssistant, host: str): _LOGGER.warning( "Could not find serial from SSDP, attempting to retrieve serial from SSDP description URL" ) - upnp_serial, model = await YamahaConfigInfo.get_upnp_serial_and_model(host, async_get_clientsession(hass)) + upnp_serial, model = await YamahaConfigInfo.get_upnp_serial_and_model( + host, async_get_clientsession(hass) + ) return upnp_serial, model @@ -56,7 +58,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) info = YamahaConfigInfo(entry.data[CONF_HOST]) - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( rxv.RXV, info.ctrl_url, entry.data[CONF_MODEL], entry.data[CONF_SERIAL] ) + hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + rxv.RXV, info.ctrl_url, entry.data[CONF_MODEL], entry.data[CONF_SERIAL] + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index 24b52a3eff5376..30dfa471b92f22 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -70,7 +70,7 @@ async def async_step_user( # Check if device is a Yamaha receiver try: info = YamahaConfigInfo(host) - await self.hass.async_add_executor_job( rxv.RXV, info.ctrl_url ) + await self.hass.async_add_executor_job(rxv.RXV, info.ctrl_url) serial_number, model = await get_upnp_serial_and_model(self.hass, host) except ConnectionError: errors["base"] = "cannot_connect" @@ -94,7 +94,10 @@ async def async_step_user( CONF_NAME: user_input.get(CONF_NAME) or DEFAULT_NAME, }, options={ - OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) or [], + OPTION_INPUT_SOURCES_IGNORE: user_input.get( + OPTION_INPUT_SOURCES_IGNORE + ) + or [], OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, }, ) @@ -116,7 +119,7 @@ async def async_step_ssdp( ) -> data_entry_flow.ConfigFlowResult: """Handle ssdp discoveries.""" if not await YamahaConfigInfo.check_yamaha_ssdp( - discovery_info.ssdp_location, async_get_clientsession(self.hass) + discovery_info.ssdp_location, async_get_clientsession(self.hass) ): return self.async_abort(reason="yxc_control_url_missing") self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] @@ -158,7 +161,10 @@ async def async_step_confirm( CONF_NAME: DEFAULT_NAME, }, options={ - OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) or [], + OPTION_INPUT_SOURCES_IGNORE: user_input.get( + OPTION_INPUT_SOURCES_IGNORE + ) + or [], OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, }, ) @@ -183,7 +189,7 @@ async def async_step_import(self, import_data: dict) -> data_entry_flow.FlowResu @staticmethod @callback def async_get_options_flow( - config_entry: ConfigEntry, + config_entry: ConfigEntry, ) -> OptionsFlow: """Return the options flow.""" return YamahaOptionsFlowHandler(config_entry) @@ -194,47 +200,63 @@ class YamahaOptionsFlowHandler(OptionsFlow): def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" - self._input_sources_ignore: list[str] = config_entry.options[OPTION_INPUT_SOURCES_IGNORE] + self._input_sources_ignore: list[str] = config_entry.options[ + OPTION_INPUT_SOURCES_IGNORE + ] self._input_sources: dict[str, str] = config_entry.options[OPTION_INPUT_SOURCES] async def async_step_init( - self, user_input: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" yamaha = self.hass.data[DOMAIN][self.config_entry.entry_id] - inputs = await self.hass.async_add_executor_job( yamaha.inputs ) + inputs = await self.hass.async_add_executor_job(yamaha.inputs) if user_input is not None: - sources_store: dict[str, str] = {k: v for k, v in user_input.items() if k in inputs and v != ""} + sources_store: dict[str, str] = { + k: v for k, v in user_input.items() if k in inputs and v != "" + } return self.async_create_entry( data={ OPTION_INPUT_SOURCES: sources_store, - OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) + OPTION_INPUT_SOURCES_IGNORE: user_input.get( + OPTION_INPUT_SOURCES_IGNORE + ), } ) schema_dict: dict[Any, Selector] = {} - available_inputs = [ SelectOptionDict(value=k, label=k) for k, v in inputs.items() ] + available_inputs = [ + SelectOptionDict(value=k, label=k) for k, v in inputs.items() + ] schema_dict[vol.Optional(OPTION_INPUT_SOURCES_IGNORE)] = SelectSelector( - SelectSelectorConfig(options=available_inputs, mode=SelectSelectorMode.DROPDOWN, multiple=True) + SelectSelectorConfig( + options=available_inputs, + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + ) ) for source in inputs: if source not in self._input_sources_ignore: - schema_dict[vol.Optional(source, default="")] = ( - TextSelector() - ) + schema_dict[vol.Optional(source, default="")] = TextSelector() options = self.config_entry.options.copy() if OPTION_INPUT_SOURCES_IGNORE in self.config_entry.options: - options[OPTION_INPUT_SOURCES_IGNORE] = self.config_entry.options[OPTION_INPUT_SOURCES_IGNORE] + options[OPTION_INPUT_SOURCES_IGNORE] = self.config_entry.options[ + OPTION_INPUT_SOURCES_IGNORE + ] if OPTION_INPUT_SOURCES in self.config_entry.options: - for source, source_name in self.config_entry.options[OPTION_INPUT_SOURCES].items(): + for source, source_name in self.config_entry.options[ + OPTION_INPUT_SOURCES + ].items(): options[source] = source_name return self.async_show_form( step_id="init", - data_schema=self.add_suggested_values_to_schema( vol.Schema( schema_dict ), options ), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), options + ), ) diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 65a0232e568fb1..ff39bbf81aa534 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -70,9 +70,9 @@ async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Yamaha zones based on a config entry.""" device: rxv.RXV = hass.data[DOMAIN][entry.entry_id] @@ -93,10 +93,10 @@ async def async_setup_entry( async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Yamaha platform.""" if config.get(CONF_HOST): @@ -178,7 +178,7 @@ def __init__( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer=BRAND, name=name + " " + zctrl.zone, - model=zctrl.model_name + model=zctrl.model_name, ) def update(self) -> None: @@ -255,7 +255,7 @@ def supported_features(self) -> MediaPlayerEntityFeature: supports = self._playback_support mapping = { "play": ( - MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA + MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA ), "pause": MediaPlayerEntityFeature.PAUSE, "stop": MediaPlayerEntityFeature.STOP, @@ -317,7 +317,7 @@ def select_source(self, source: str) -> None: self.zctrl.input = self._reverse_mapping.get(source, source) def play_media( - self, media_type: MediaType | str, media_id: str, **kwargs: Any + self, media_type: MediaType | str, media_id: str, **kwargs: Any ) -> None: """Play media from an ID. diff --git a/homeassistant/components/yamaha/yamaha_config_info.py b/homeassistant/components/yamaha/yamaha_config_info.py index 2669cda05ef46a..2f21d62f88ba31 100644 --- a/homeassistant/components/yamaha/yamaha_config_info.py +++ b/homeassistant/components/yamaha/yamaha_config_info.py @@ -9,9 +9,7 @@ class YamahaConfigInfo: """Configuration Info for Yamaha Receivers.""" - def __init__( - self, host: str - ) -> None: + def __init__(self, host: str) -> None: """Initialize the Configuration Info for Yamaha Receiver.""" self.ctrl_url: str | None = f"http://{host}:80/YamahaRemoteControl/ctrl" @@ -20,12 +18,17 @@ async def check_yamaha_ssdp(cls, location: str, client: ClientSession): """Check if the Yamaha receiver has a valid control URL.""" res = await client.get(location) text = await res.text() - return text.find('/YamahaRemoteControl/ctrl') != -1 + return ( + text.find( + "/YamahaRemoteControl/ctrl" + ) + != -1 + ) @classmethod - async def get_upnp_serial_and_model(cls, host : str, client: ClientSession): + async def get_upnp_serial_and_model(cls, host: str, client: ClientSession): """Retrieve the serial_number and model from the SSDP description URL.""" - res = await client.get(f"http://{host}:49154/MediaRenderer/desc.xml" ) + res = await client.get(f"http://{host}:49154/MediaRenderer/desc.xml") root = ET.fromstring(await res.text()) serial_number = root.find("./s:device/s:serialNumber", ns).text model_name = root.find("./s:device/s:modelName", ns).text diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py index 81a9352477c838..943b3a526eb574 100644 --- a/tests/components/yamaha/test_config_flow.py +++ b/tests/components/yamaha/test_config_flow.py @@ -37,21 +37,14 @@ def silent_ssdp_scanner() -> Generator[None]: @pytest.fixture(autouse=True) def mock_setup_entry(): """Mock setting up a config entry.""" - with patch( - "homeassistant.components.yamaha.async_setup_entry", return_value=True - ): + with patch("homeassistant.components.yamaha.async_setup_entry", return_value=True): yield @pytest.fixture def mock_get_device_info_valid(): """Mock getting valid device info from Yamaha API.""" - with ( - patch( - "rxv.RXV", - return_value=Mock() - ) - ): + with patch("rxv.RXV", return_value=Mock()): yield @@ -60,8 +53,11 @@ def mock_get_device_info_invalid(): """Mock getting invalid device info from Yamaha API.""" with ( patch("rxv.RXV", return_value=None), - patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=(None, None)) - ) : + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", + return_value=(None, None), + ), + ): yield @@ -69,8 +65,8 @@ def mock_get_device_info_invalid(): def mock_get_device_info_mc_exception(): """Mock raising an unexpected Exception.""" with patch( - "rxv.RXV", - side_effect=ConnectionError("mocked error"), + "rxv.RXV", + side_effect=ConnectionError("mocked error"), ): yield @@ -78,14 +74,20 @@ def mock_get_device_info_mc_exception(): @pytest.fixture def mock_ssdp_yamaha(): """Mock that the SSDP detected device is a Yamaha device.""" - with patch("homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", return_value=True): + with patch( + "homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", + return_value=True, + ): yield @pytest.fixture def mock_ssdp_no_yamaha(): """Mock that the SSDP detected device is not a Yamaha device.""" - with patch("homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", return_value=False): + with patch( + "homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", + return_value=False, + ): yield @@ -116,8 +118,14 @@ def mock_valid_discovery_information(): def mock_empty_discovery_information(): """Mock that the ssdp scanner returns no upnp description.""" with ( - patch("homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[]), - patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=("1234567890","MC20")) + patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[], + ), + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", + return_value=("1234567890", "MC20"), + ), ): yield @@ -185,9 +193,7 @@ async def test_user_input_device_already_existing( assert result2["reason"] == "already_configured" -async def test_user_input_unknown_error( - hass: HomeAssistant -) -> None: +async def test_user_input_unknown_error(hass: HomeAssistant) -> None: """Test when user specifies an existing device, which does not provide the Yamaha API.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index 5aa514a0f5082c..4969497eed112d 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -48,7 +48,10 @@ def device_fixture(main_zone): device = FakeYamahaDevice("http://receiver", "Receiver", zones=[main_zone]) with ( patch("rxv.RXV", return_value=device), - patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=("1234567890", "MC20")) + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", + return_value=("1234567890", "MC20"), + ), ): yield device @@ -61,7 +64,10 @@ def device2_fixture(main_zone): ) with ( patch("rxv.RXV", return_value=device), - patch("homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", return_value=("0987654321", "AX100")) + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", + return_value=("0987654321", "AX100"), + ), ): yield device @@ -117,7 +123,9 @@ async def test_setup_no_host(hass: HomeAssistant, device, main_zone) -> None: assert state is None -@pytest.mark.skip(reason="Remove this since it relies on a removed Disovery integration?") +@pytest.mark.skip( + reason="Remove this since it relies on a removed Disovery integration?" +) async def test_setup_discovery(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration via discovery.""" discovery_info = { @@ -137,7 +145,9 @@ async def test_setup_discovery(hass: HomeAssistant, device, main_zone) -> None: assert state.state == "off" -@pytest.mark.skip(reason="Remove this since zone_ignore and zone_names were removed from configation.yaml?") +@pytest.mark.skip( + reason="Remove this since zone_ignore and zone_names were removed from configation.yaml?" +) async def test_setup_zone_ignore(hass: HomeAssistant, device, main_zone) -> None: """Test set up integration without host.""" assert await async_setup_component( From f852ee38d3cacdc9a081bb10dd8bb5f9b1cee912 Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 22 Nov 2024 10:59:12 +0100 Subject: [PATCH 03/14] #130820 Add config flow to Yamaha (format with Prettier) --- homeassistant/components/yamaha/manifest.json | 6 +-- homeassistant/components/yamaha/strings.json | 46 +++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json index d4683024b41276..053a92f4c4fe46 100644 --- a/homeassistant/components/yamaha/manifest.json +++ b/homeassistant/components/yamaha/manifest.json @@ -2,8 +2,8 @@ "domain": "yamaha", "name": "Yamaha Network Receivers", "codeowners": [], - "config_flow": true, - "dependencies": ["ssdp"], + "config_flow": true, + "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/yamaha", "iot_class": "local_polling", "loggers": ["rxv"], @@ -15,5 +15,5 @@ { "manufacturer": "Yamaha Corporation" } - ] + ] } diff --git a/homeassistant/components/yamaha/strings.json b/homeassistant/components/yamaha/strings.json index 720b1fb665e81d..3cfd4c9ed10db0 100644 --- a/homeassistant/components/yamaha/strings.json +++ b/homeassistant/components/yamaha/strings.json @@ -1,30 +1,30 @@ { "config": { - "flow_title": "Receiver: {name}", - "step": { - "user": { - "description": "Set up Yamaha to integrate with Home Assistant.", - "data": { - "host": "[%key:common::config_flow::data::host%]" - }, - "data_description": { - "host": "Hostname or IP address of your Yamaha receiver." - } - }, - "confirm": { - "description": "[%key:common::config_flow::description::confirm_setup%]" - } - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "yxc_control_url_missing": "The control URL is not given in the ssdp description." + "flow_title": "Receiver: {name}", + "step": { + "user": { + "description": "Set up Yamaha to integrate with Home Assistant.", + "data": { + "host": "[%key:common::config_flow::data::host%]" }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "no_yamaha_device": "This device seems to be no Yamaha Device.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "data_description": { + "host": "Hostname or IP address of your Yamaha receiver." } - }, + }, + "confirm": { + "description": "[%key:common::config_flow::description::confirm_setup%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "yxc_control_url_missing": "The control URL is not given in the ssdp description." + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_yamaha_device": "This device seems to be no Yamaha Device.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, "options": { "step": { "init": { From cd9030ee7cb614ce9b51e73fe627f9b6fac9f9da Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 22 Nov 2024 12:37:32 +0100 Subject: [PATCH 04/14] #130820 Add config flow to Yamaha (format with Prettier) --- .../components/yamaha/config_flow.py | 15 +++--- .../components/yamaha/media_player.py | 9 ++-- .../components/yamaha/yamaha_config_info.py | 6 ++- tests/components/yamaha/test_media_player.py | 46 ------------------- 4 files changed, 16 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index 30dfa471b92f22..8c461f38025e2f 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -56,7 +56,7 @@ class YamahaFlowHandler(ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.ConfigFlowResult: + ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" # Request user input, unless we are preparing discovery flow if user_input is None: @@ -86,7 +86,7 @@ async def async_step_user( self._abort_if_unique_id_configured() return self.async_create_entry( - title=model, + title=model or DEFAULT_NAME, data={ CONF_HOST: host, CONF_MODEL: model, @@ -104,9 +104,7 @@ async def async_step_user( return self._show_setup_form(errors) - def _show_setup_form( - self, errors: dict | None = None - ) -> data_entry_flow.ConfigFlowResult: + def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: """Show the setup form to the user.""" return self.async_show_form( step_id="user", @@ -116,8 +114,9 @@ def _show_setup_form( async def async_step_ssdp( self, discovery_info: ssdp.SsdpServiceInfo - ) -> data_entry_flow.ConfigFlowResult: + ) -> ConfigFlowResult: """Handle ssdp discoveries.""" + assert discovery_info.ssdp_location is not None if not await YamahaConfigInfo.check_yamaha_ssdp( discovery_info.ssdp_location, async_get_clientsession(self.hass) ): @@ -153,7 +152,7 @@ async def async_step_confirm( """Allow the user to confirm adding the device.""" if user_input is not None: return self.async_create_entry( - title=self.model, + title=self.model or DEFAULT_NAME, data={ CONF_HOST: self.host, CONF_MODEL: self.model, @@ -171,7 +170,7 @@ async def async_step_confirm( return self.async_show_form(step_id="confirm") - async def async_step_import(self, import_data: dict) -> data_entry_flow.FlowResult: + async def async_step_import(self, import_data: dict) -> ConfigFlowResult: """Import data from configuration.yaml into the config flow.""" res = await self.async_step_user(import_data) if res["type"] == FlowResultType.CREATE_ENTRY: diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index ff39bbf81aa534..a63b473c390225 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -33,6 +33,7 @@ CURSOR_TYPE_RIGHT, CURSOR_TYPE_SELECT, CURSOR_TYPE_UP, + DEFAULT_NAME, DOMAIN, OPTION_INPUT_SOURCES, OPTION_INPUT_SOURCES_IGNORE, @@ -81,7 +82,7 @@ async def async_setup_entry( for zctrl in device.zone_controllers(): entity = YamahaDeviceZone( - entry.data.get(CONF_NAME), + entry.data.get(CONF_NAME, DEFAULT_NAME), zctrl, entry.options.get(OPTION_INPUT_SOURCES_IGNORE), entry.options.get(OPTION_INPUT_SOURCES), @@ -105,12 +106,12 @@ async def async_setup_platform( ]: _LOGGER.error( "Configuration in configuration.yaml is not supported anymore. " - "Please add this device using the config flow: %s.", + "Please add this device using the config flow: %s", config[CONF_HOST], ) else: _LOGGER.warning( - "Configuration in configuration.yaml is deprecated. Use the config flow instead." + "Configuration in configuration.yaml is deprecated. Use the config flow instead" ) hass.async_create_task( @@ -121,7 +122,7 @@ async def async_setup_platform( else: _LOGGER.error( "Configuration in configuration.yaml is not supported anymore. " - "Please add this device using the config flow." + "Please add this device using the config flow" ) # Register Service 'select_scene' diff --git a/homeassistant/components/yamaha/yamaha_config_info.py b/homeassistant/components/yamaha/yamaha_config_info.py index 2f21d62f88ba31..351f099f767242 100644 --- a/homeassistant/components/yamaha/yamaha_config_info.py +++ b/homeassistant/components/yamaha/yamaha_config_info.py @@ -14,7 +14,7 @@ def __init__(self, host: str) -> None: self.ctrl_url: str | None = f"http://{host}:80/YamahaRemoteControl/ctrl" @classmethod - async def check_yamaha_ssdp(cls, location: str, client: ClientSession): + async def check_yamaha_ssdp(cls, location: str, client: ClientSession) -> bool: """Check if the Yamaha receiver has a valid control URL.""" res = await client.get(location) text = await res.text() @@ -26,7 +26,9 @@ async def check_yamaha_ssdp(cls, location: str, client: ClientSession): ) @classmethod - async def get_upnp_serial_and_model(cls, host: str, client: ClientSession): + async def get_upnp_serial_and_model( + cls, host: str, client: ClientSession + ) -> tuple[str, str]: """Retrieve the serial_number and model from the SSDP description URL.""" res = await client.get(f"http://{host}:49154/MediaRenderer/desc.xml") root = ET.fromstring(await res.text()) diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index 4969497eed112d..c87a387a83e19c 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -8,7 +8,6 @@ from homeassistant.components.yamaha import media_player as yamaha from homeassistant.components.yamaha.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers.discovery import async_load_platform from homeassistant.setup import async_setup_component CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} @@ -123,51 +122,6 @@ async def test_setup_no_host(hass: HomeAssistant, device, main_zone) -> None: assert state is None -@pytest.mark.skip( - reason="Remove this since it relies on a removed Disovery integration?" -) -async def test_setup_discovery(hass: HomeAssistant, device, main_zone) -> None: - """Test set up integration via discovery.""" - discovery_info = { - "name": "Yamaha Receiver", - "model_name": "Yamaha", - "control_url": "http://receiver", - "description_url": "http://receiver/description", - } - await async_load_platform( - hass, MP_DOMAIN, "yamaha", discovery_info, {MP_DOMAIN: {}} - ) - await hass.async_block_till_done() - - state = hass.states.get("media_player.yamaha_receiver_main_zone") - - assert state is not None - assert state.state == "off" - - -@pytest.mark.skip( - reason="Remove this since zone_ignore and zone_names were removed from configation.yaml?" -) -async def test_setup_zone_ignore(hass: HomeAssistant, device, main_zone) -> None: - """Test set up integration without host.""" - assert await async_setup_component( - hass, - MP_DOMAIN, - { - "media_player": { - "platform": "yamaha", - "host": "127.0.0.1", - "zone_ignore": "Main zone", - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("media_player.yamaha_receiver_main_zone") - - assert state is None - - async def test_enable_output(hass: HomeAssistant, device, main_zone) -> None: """Test enable output service.""" assert await async_setup_component(hass, MP_DOMAIN, CONFIG) From 99bf77b14b4e77e7e9aa8bbb6a360eb5413ebb8c Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 22 Nov 2024 18:55:42 +0100 Subject: [PATCH 05/14] #130820 Add config flow to Yamaha (format with Prettier) --- homeassistant/components/yamaha/__init__.py | 2 +- homeassistant/components/yamaha/media_player.py | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/yamaha/__init__.py b/homeassistant/components/yamaha/__init__.py index eb517be53dc483..417fdb43daaf09 100644 --- a/homeassistant/components/yamaha/__init__.py +++ b/homeassistant/components/yamaha/__init__.py @@ -58,7 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) info = YamahaConfigInfo(entry.data[CONF_HOST]) - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + entry.runtime_data = await hass.async_add_executor_job( rxv.RXV, info.ctrl_url, entry.data[CONF_MODEL], entry.data[CONF_SERIAL] ) diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index a63b473c390225..13b366a5af620c 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components.media_player import ( + PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -69,6 +70,13 @@ | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) +PLATFORM_SCHEMA = MEDIA_PLAYER_PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_HOST): cv.string, + } +) + async def async_setup_entry( hass: HomeAssistant, @@ -76,7 +84,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Yamaha zones based on a config entry.""" - device: rxv.RXV = hass.data[DOMAIN][entry.entry_id] + device: rxv.RXV = entry.runtime_data media_players: list[Entity] = [] @@ -110,10 +118,6 @@ async def async_setup_platform( config[CONF_HOST], ) else: - _LOGGER.warning( - "Configuration in configuration.yaml is deprecated. Use the config flow instead" - ) - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=config From 83f0ee5fa56c43d24c1ba1aeebabca7e8584406f Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Mon, 25 Nov 2024 17:40:26 +0100 Subject: [PATCH 06/14] #130820 Add config flow to Yamaha * Realign code with yamaha_musiccast and store UPNP Description instead of model and name * Reuse rxv.ssdp.rxv_details for querying the rxv data instead of querying from HA code --- homeassistant/components/yamaha/__init__.py | 46 +++++-------- .../components/yamaha/config_flow.py | 37 +++++----- homeassistant/components/yamaha/const.py | 6 +- .../components/yamaha/media_player.py | 12 ++++ .../components/yamaha/yamaha_config_info.py | 36 ++++------ tests/components/yamaha/test_config_flow.py | 68 ++++++++++++------- tests/components/yamaha/test_media_player.py | 21 ++++-- 7 files changed, 122 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/yamaha/__init__.py b/homeassistant/components/yamaha/__init__.py index 417fdb43daaf09..82eeae8a4485d0 100644 --- a/homeassistant/components/yamaha/__init__.py +++ b/homeassistant/components/yamaha/__init__.py @@ -2,17 +2,17 @@ from __future__ import annotations +from functools import partial import logging import rxv from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_MODEL, CONF_SERIAL, DOMAIN +from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN from .yamaha_config_info import YamahaConfigInfo PLATFORMS = [Platform.MEDIA_PLAYER] @@ -20,48 +20,42 @@ _LOGGER = logging.getLogger(__name__) -async def get_upnp_serial_and_model(hass: HomeAssistant, host: str): - """Get the upnp serial and model for a given host, using the SSPD scanner.""" +async def get_upnp_desc(hass: HomeAssistant, host: str) -> str: + """Get the upnp description URL for a given host, using the SSPD scanner.""" ssdp_entries = await ssdp.async_get_discovery_info_by_st(hass, "upnp:rootdevice") matches = [w for w in ssdp_entries if w.ssdp_headers.get("_host", "") == host] - upnp_serial = None - model = None + upnp_desc = None for match in matches: - if match.ssdp_location: - upnp_serial = match.upnp[ssdp.ATTR_UPNP_SERIAL] - model = match.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + if upnp_desc := match.ssdp_location: break - if upnp_serial is None: + if not upnp_desc: _LOGGER.warning( - "Could not find serial from SSDP, attempting to retrieve serial from SSDP description URL" + "The upnp_description was not found automatically, setting a default one" ) - upnp_serial, model = await YamahaConfigInfo.get_upnp_serial_and_model( - host, async_get_clientsession(hass) - ) - return upnp_serial, model + upnp_desc = f"http://{host}:49154/MediaRenderer/desc.xml" + return upnp_desc async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Yamaha from a config entry.""" - if entry.data.get(CONF_NAME) is None: - upnp, model = await get_upnp_serial_and_model(hass, entry.data[CONF_HOST]) + if entry.data.get(CONF_UPNP_DESC) is None: hass.config_entries.async_update_entry( entry, data={ CONF_HOST: entry.data[CONF_HOST], CONF_SERIAL: entry.data[CONF_SERIAL], - CONF_NAME: upnp[ssdp.ATTR_UPNP_MODEL_NAME], - CONF_MODEL: entry.data[CONF_MODEL], + CONF_UPNP_DESC: await get_upnp_desc(hass, entry.data[CONF_HOST]), }, ) hass.data.setdefault(DOMAIN, {}) - info = YamahaConfigInfo(entry.data[CONF_HOST]) + rxv_details = await YamahaConfigInfo.get_rxv_details( + entry.data[CONF_UPNP_DESC], hass + ) entry.runtime_data = await hass.async_add_executor_job( - rxv.RXV, info.ctrl_url, entry.data[CONF_MODEL], entry.data[CONF_SERIAL] + partial(rxv.RXV, **rxv_details._asdict()) # type: ignore[union-attr] ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_reload_entry)) @@ -70,11 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index 8c461f38025e2f..e4370950d63207 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -18,10 +18,9 @@ ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.selector import ( SelectOptionDict, Selector, @@ -31,10 +30,10 @@ TextSelector, ) -from . import get_upnp_serial_and_model +from . import get_upnp_desc from .const import ( - CONF_MODEL, CONF_SERIAL, + CONF_UPNP_DESC, DEFAULT_NAME, DOMAIN, OPTION_INPUT_SOURCES, @@ -51,8 +50,8 @@ class YamahaFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 serial_number: str | None = None - model: str | None = None host: str + upnp_description: str | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -64,21 +63,19 @@ async def async_step_user( host = user_input[CONF_HOST] serial_number = None - model = None errors = {} # Check if device is a Yamaha receiver try: - info = YamahaConfigInfo(host) - await self.hass.async_add_executor_job(rxv.RXV, info.ctrl_url) - serial_number, model = await get_upnp_serial_and_model(self.hass, host) + upnp_desc: str = await get_upnp_desc(self.hass, host) + info = await YamahaConfigInfo.get_rxv_details(upnp_desc, self.hass) except ConnectionError: errors["base"] = "cannot_connect" except Exception: _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - if serial_number is None: + if info is None or (serial_number := info.serial_number) is None: errors["base"] = "no_yamaha_device" if not errors: @@ -86,12 +83,11 @@ async def async_step_user( self._abort_if_unique_id_configured() return self.async_create_entry( - title=model or DEFAULT_NAME, + title=DEFAULT_NAME, data={ CONF_HOST: host, - CONF_MODEL: model, CONF_SERIAL: serial_number, - CONF_NAME: user_input.get(CONF_NAME) or DEFAULT_NAME, + CONF_UPNP_DESC: await get_upnp_desc(self.hass, host), }, options={ OPTION_INPUT_SOURCES_IGNORE: user_input.get( @@ -118,11 +114,11 @@ async def async_step_ssdp( """Handle ssdp discoveries.""" assert discovery_info.ssdp_location is not None if not await YamahaConfigInfo.check_yamaha_ssdp( - discovery_info.ssdp_location, async_get_clientsession(self.hass) + discovery_info.ssdp_location, self.hass ): return self.async_abort(reason="yxc_control_url_missing") self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] - self.model = discovery_info.upnp[ssdp.ATTR_UPNP_MODEL_NAME] + self.upnp_description = discovery_info.ssdp_location # ssdp_location and hostname have been checked in check_yamaha_ssdp so it is safe to ignore type assignment self.host = urlparse(discovery_info.ssdp_location).hostname # type: ignore[assignment] @@ -131,7 +127,7 @@ async def async_step_ssdp( self._abort_if_unique_id_configured( { CONF_HOST: self.host, - CONF_NAME: self.model, + CONF_UPNP_DESC: self.upnp_description, } ) self.context.update( @@ -152,12 +148,11 @@ async def async_step_confirm( """Allow the user to confirm adding the device.""" if user_input is not None: return self.async_create_entry( - title=self.model or DEFAULT_NAME, + title=DEFAULT_NAME, data={ CONF_HOST: self.host, - CONF_MODEL: self.model, CONF_SERIAL: self.serial_number, - CONF_NAME: DEFAULT_NAME, + CONF_UPNP_DESC: self.upnp_description, }, options={ OPTION_INPUT_SOURCES_IGNORE: user_input.get( @@ -208,8 +203,8 @@ async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Manage the options.""" - yamaha = self.hass.data[DOMAIN][self.config_entry.entry_id] - inputs = await self.hass.async_add_executor_job(yamaha.inputs) + yamaha: rxv.RXV = self.config_entry.runtime_data + inputs: dict[str, str] = await self.hass.async_add_executor_job(yamaha.inputs) if user_input is not None: sources_store: dict[str, str] = { diff --git a/homeassistant/components/yamaha/const.py b/homeassistant/components/yamaha/const.py index 71a9bef5fa868d..acf57586df0ffe 100644 --- a/homeassistant/components/yamaha/const.py +++ b/homeassistant/components/yamaha/const.py @@ -3,7 +3,11 @@ DOMAIN = "yamaha" BRAND = "Yamaha Corporation" CONF_SERIAL = "serial" -CONF_MODEL = "model" +CONF_UPNP_DESC = "upnp_description" +CONF_SOURCE_IGNORE = "source_ignore" +CONF_SOURCE_NAMES = "source_names" +CONF_ZONE_IGNORE = "zone_ignore" +CONF_ZONE_NAMES = "zone_names" CURSOR_TYPE_DOWN = "down" CURSOR_TYPE_LEFT = "left" CURSOR_TYPE_RETURN = "return" diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index 13b366a5af620c..ffe734bfbabdf5 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -28,6 +28,10 @@ from .const import ( BRAND, + CONF_SOURCE_IGNORE, + CONF_SOURCE_NAMES, + CONF_ZONE_IGNORE, + CONF_ZONE_NAMES, CURSOR_TYPE_DOWN, CURSOR_TYPE_LEFT, CURSOR_TYPE_RETURN, @@ -74,6 +78,14 @@ { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_SOURCE_IGNORE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_ZONE_IGNORE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string}, + vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string}, } ) diff --git a/homeassistant/components/yamaha/yamaha_config_info.py b/homeassistant/components/yamaha/yamaha_config_info.py index 351f099f767242..f298a9d64a9a60 100644 --- a/homeassistant/components/yamaha/yamaha_config_info.py +++ b/homeassistant/components/yamaha/yamaha_config_info.py @@ -1,37 +1,25 @@ """Configuration Information for Yamaha.""" -from aiohttp import ClientSession -import defusedxml.ElementTree as ET +from rxv import ssdp +from rxv.ssdp import RxvDetails -ns = {"s": "urn:schemas-upnp-org:device-1-0"} +from homeassistant.core import HomeAssistant class YamahaConfigInfo: - """Configuration Info for Yamaha Receivers.""" - - def __init__(self, host: str) -> None: - """Initialize the Configuration Info for Yamaha Receiver.""" - self.ctrl_url: str | None = f"http://{host}:80/YamahaRemoteControl/ctrl" + """Check and retrieve configuration Info for Yamaha Receivers.""" @classmethod - async def check_yamaha_ssdp(cls, location: str, client: ClientSession) -> bool: + async def check_yamaha_ssdp(cls, location: str, hass: HomeAssistant) -> bool: """Check if the Yamaha receiver has a valid control URL.""" - res = await client.get(location) - text = await res.text() - return ( - text.find( - "/YamahaRemoteControl/ctrl" - ) - != -1 + details: RxvDetails | None = await YamahaConfigInfo.get_rxv_details( + location, hass ) + return (details and details.ctrl_url) is not None @classmethod - async def get_upnp_serial_and_model( - cls, host: str, client: ClientSession - ) -> tuple[str, str]: + async def get_rxv_details( + cls, location: str, hass: HomeAssistant + ) -> RxvDetails | None: """Retrieve the serial_number and model from the SSDP description URL.""" - res = await client.get(f"http://{host}:49154/MediaRenderer/desc.xml") - root = ET.fromstring(await res.text()) - serial_number = root.find("./s:device/s:serialNumber", ns).text - model_name = root.find("./s:device/s:modelName", ns).text - return serial_number, model_name + return await hass.async_add_executor_job(ssdp.rxv_details, location) diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py index 943b3a526eb574..508eefa3091abd 100644 --- a/tests/components/yamaha/test_config_flow.py +++ b/tests/components/yamaha/test_config_flow.py @@ -5,6 +5,7 @@ import pytest from requests.exceptions import ConnectionError +from rxv.ssdp import RxvDetails from homeassistant import config_entries from homeassistant.components import ssdp @@ -54,8 +55,8 @@ def mock_get_device_info_invalid(): with ( patch("rxv.RXV", return_value=None), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", - return_value=(None, None), + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + return_value=None, ), ): yield @@ -94,22 +95,34 @@ def mock_ssdp_no_yamaha(): @pytest.fixture def mock_valid_discovery_information(): """Mock that the ssdp scanner returns a useful upnp description.""" - with patch( - "homeassistant.components.ssdp.async_get_discovery_info_by_st", - return_value=[ - ssdp.SsdpServiceInfo( - ssdp_usn="mock_usn", - ssdp_st="mock_st", - ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", - ssdp_headers={ - "_host": "127.0.0.1", - }, - upnp={ - ssdp.ATTR_UPNP_SERIAL: "1234567890", - ssdp.ATTR_UPNP_MODEL_NAME: "MC20", - }, - ) - ], + with ( + patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[ + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", + ssdp_headers={ + "_host": "127.0.0.1", + }, + upnp={ + ssdp.ATTR_UPNP_SERIAL: "1234567890", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + }, + ) + ], + ), + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + return_value=RxvDetails( + model_name="MC20", + ctrl_url=None, + unit_desc_url=None, + friendly_name=None, + serial_number="1234567890", + ), + ), ): yield @@ -123,8 +136,14 @@ def mock_empty_discovery_information(): return_value=[], ), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", - return_value=("1234567890", "MC20"), + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + return_value=RxvDetails( + model_name="MC20", + ctrl_url=None, + unit_desc_url=None, + friendly_name=None, + serial_number="1234567890", + ), ), ): yield @@ -229,9 +248,8 @@ async def test_user_input_device_found( assert isinstance(result2["result"], ConfigEntry) assert result2["data"] == { "host": "127.0.0.1", - "model": "MC20", "serial": "1234567890", - "name": "Yamaha Receiver", + "upnp_description": "http://127.0.0.1:9000/MediaRenderer/desc.xml", } @@ -255,9 +273,8 @@ async def test_user_input_device_found_no_ssdp( assert isinstance(result2["result"], ConfigEntry) assert result2["data"] == { "host": "127.0.0.1", - "model": "MC20", "serial": "1234567890", - "name": "Yamaha Receiver", + "upnp_description": "http://127.0.0.1:49154/MediaRenderer/desc.xml", } @@ -315,9 +332,8 @@ async def test_ssdp_discovery_successful_add_device( assert isinstance(result2["result"], ConfigEntry) assert result2["data"] == { "host": "127.0.0.1", - "model": "MC20", "serial": "1234567890", - "name": "Yamaha Receiver", + "upnp_description": "http://127.0.0.1/desc.xml", } diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index c87a387a83e19c..e2956edcccf774 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, PropertyMock, call, patch import pytest +from rxv.ssdp import RxvDetails from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.yamaha import media_player as yamaha @@ -48,8 +49,14 @@ def device_fixture(main_zone): with ( patch("rxv.RXV", return_value=device), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", - return_value=("1234567890", "MC20"), + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + return_value=RxvDetails( + model_name="MC20", + ctrl_url=None, + unit_desc_url=None, + friendly_name=None, + serial_number="1234567890", + ), ), ): yield device @@ -64,8 +71,14 @@ def device2_fixture(main_zone): with ( patch("rxv.RXV", return_value=device), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_upnp_serial_and_model", - return_value=("0987654321", "AX100"), + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + return_value=RxvDetails( + model_name="AX100", + ctrl_url=None, + unit_desc_url=None, + friendly_name=None, + serial_number="0987654321", + ), ), ): yield device From e1edf38e9fe5df9a0fcf83249e0d6080dcabbd88 Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Mon, 25 Nov 2024 18:23:32 +0100 Subject: [PATCH 07/14] #130820 Add config flow to Yamaha, cover OptionsFlow --- tests/components/yamaha/__init__.py | 31 ++++++++++++++++ tests/components/yamaha/test_config_flow.py | 41 +++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/tests/components/yamaha/__init__.py b/tests/components/yamaha/__init__.py index 0df69c55380b0b..8ab2f52786e72c 100644 --- a/tests/components/yamaha/__init__.py +++ b/tests/components/yamaha/__init__.py @@ -1 +1,32 @@ """Tests for the yamaha component.""" + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def create_empty_config_entry() -> MockConfigEntry: + """Create an empty config entry for use in unit tests.""" + data = {CONF_HOST: ""} + options = { + "source_ignore": [], + "source_names": {"AV2": "Screen 2"}, + } + + return MockConfigEntry( + data=data, + options=options, + title="Unit test Yamaha", + domain="yamaha", + unique_id="yamaha_unique_id", + ) + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py index 508eefa3091abd..8d8329eb704c4f 100644 --- a/tests/components/yamaha/test_config_flow.py +++ b/tests/components/yamaha/test_config_flow.py @@ -15,6 +15,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from . import create_empty_config_entry, setup_integration + from tests.common import MockConfigEntry @@ -149,6 +151,16 @@ def mock_empty_discovery_information(): yield +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create Onkyo entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="Yamaha Receiver", + data={}, + ) + + # User Flows @@ -364,3 +376,32 @@ async def test_ssdp_discovery_existing_device_update( assert result["reason"] == "already_configured" assert mock_entry.data[CONF_HOST] == "127.0.0.1" assert mock_entry.data["model"] == "MC20" + + +async def test_options_flow(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Test options flow.""" + + config_entry = create_empty_config_entry() + fake_rxv = Mock() + fake_rxv.inputs = lambda: {"Napster": "Napster", "AV1": None, "AV2": None} + config_entry.runtime_data = fake_rxv + await setup_integration(hass, config_entry) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "AV1": "Projector", + "AV2": "TV", + "source_ignore": ["Napster"], + }, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == { + "source_ignore": ["Napster"], + "source_names": {"AV1": "Projector", "AV2": "TV"}, + } From 00301c3ac9e6f0ee6a6a5a57a1e3f86929a16c8c Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Mon, 25 Nov 2024 18:35:04 +0100 Subject: [PATCH 08/14] #130820 Add config flow to Yamaha --- homeassistant/components/yamaha/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json index 5118c9179c0130..3a5df7b2dd6f80 100644 --- a/homeassistant/components/yamaha/manifest.json +++ b/homeassistant/components/yamaha/manifest.json @@ -14,6 +14,6 @@ { "manufacturer": "Yamaha Corporation" } - ] + ], "requirements": ["rxv==0.7.0"] } From 83702902cdec5e0400ebf4f396b88780b218b68f Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Mon, 25 Nov 2024 18:44:28 +0100 Subject: [PATCH 09/14] #130820 Add config flow to Yamaha --- homeassistant/components/yamaha/manifest.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json index 3a5df7b2dd6f80..053a92f4c4fe46 100644 --- a/homeassistant/components/yamaha/manifest.json +++ b/homeassistant/components/yamaha/manifest.json @@ -7,6 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/yamaha", "iot_class": "local_polling", "loggers": ["rxv"], + "requirements": ["rxv==0.7.0"], "ssdp": [ { "manufacturer": "YAMAHA CORPORATION" @@ -14,6 +15,5 @@ { "manufacturer": "Yamaha Corporation" } - ], - "requirements": ["rxv==0.7.0"] + ] } From 7a8f0bd6ff09659506ffb4805119b38886f8cb07 Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Wed, 27 Nov 2024 14:07:43 +0100 Subject: [PATCH 10/14] #130820 Add config flow to Yamaha - use async_abort(), add discovery when configuration yaml has no host, add issues (taken from onkyo integration) --- .../components/yamaha/config_flow.py | 60 ++++------ .../components/yamaha/media_player.py | 103 +++++++++++++++--- homeassistant/components/yamaha/strings.json | 16 ++- tests/components/yamaha/test_config_flow.py | 30 +++-- tests/components/yamaha/test_media_player.py | 21 +++- 5 files changed, 159 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index e4370950d63207..8dea1097b95133 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -20,7 +20,6 @@ ) from homeassistant.const import CONF_HOST from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.selector import ( SelectOptionDict, Selector, @@ -64,41 +63,35 @@ async def async_step_user( host = user_input[CONF_HOST] serial_number = None - errors = {} # Check if device is a Yamaha receiver try: upnp_desc: str = await get_upnp_desc(self.hass, host) info = await YamahaConfigInfo.get_rxv_details(upnp_desc, self.hass) except ConnectionError: - errors["base"] = "cannot_connect" + return self.async_abort(reason="cannot_connect") except Exception: _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" + return self.async_abort(reason="unknown") else: if info is None or (serial_number := info.serial_number) is None: - errors["base"] = "no_yamaha_device" - - if not errors: - await self.async_set_unique_id(serial_number, raise_on_progress=False) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=DEFAULT_NAME, - data={ - CONF_HOST: host, - CONF_SERIAL: serial_number, - CONF_UPNP_DESC: await get_upnp_desc(self.hass, host), - }, - options={ - OPTION_INPUT_SOURCES_IGNORE: user_input.get( - OPTION_INPUT_SOURCES_IGNORE - ) - or [], - OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, - }, - ) - - return self._show_setup_form(errors) + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(serial_number, raise_on_progress=False) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_NAME, + data={ + CONF_HOST: host, + CONF_SERIAL: serial_number, + CONF_UPNP_DESC: await get_upnp_desc(self.hass, host), + }, + options={ + OPTION_INPUT_SOURCES_IGNORE: user_input.get(OPTION_INPUT_SOURCES_IGNORE) + or [], + OPTION_INPUT_SOURCES: user_input.get(OPTION_INPUT_SOURCES) or {}, + }, + ) def _show_setup_form(self, errors: dict | None = None) -> ConfigFlowResult: """Show the setup form to the user.""" @@ -167,18 +160,7 @@ async def async_step_confirm( async def async_step_import(self, import_data: dict) -> ConfigFlowResult: """Import data from configuration.yaml into the config flow.""" - res = await self.async_step_user(import_data) - if res["type"] == FlowResultType.CREATE_ENTRY: - _LOGGER.info( - "Successfully imported %s from configuration.yaml", - import_data.get(CONF_HOST), - ) - elif res["type"] == FlowResultType.FORM: - _LOGGER.error( - "Could not import %s from configuration.yaml", - import_data.get(CONF_HOST), - ) - return res + return await self.async_step_user(import_data) @staticmethod @callback diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index ffe734bfbabdf5..c4b7a8f16ce554 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -10,6 +10,7 @@ from rxv import RXV import voluptuous as vol +from homeassistant.components import ssdp from homeassistant.components.media_player import ( PLATFORM_SCHEMA as MEDIA_PLAYER_PLATFORM_SCHEMA, MediaPlayerEntity, @@ -17,15 +18,19 @@ MediaPlayerState, MediaType, ) +from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import YamahaConfigInfo from .const import ( BRAND, CONF_SOURCE_IGNORE, @@ -89,6 +94,8 @@ } ) +ISSUE_URL_PLACEHOLDER = "/config/integrations/dashboard/add?domain=yamaha" + async def async_setup_entry( hass: HomeAssistant, @@ -120,25 +127,85 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Yamaha platform.""" - if config.get(CONF_HOST): - if hass.config_entries.async_entries(DOMAIN) and config[CONF_HOST] not in [ - entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) - ]: - _LOGGER.error( - "Configuration in configuration.yaml is not supported anymore. " - "Please add this device using the config flow: %s", - config[CONF_HOST], + host = config.get(CONF_HOST) + results = [] + if host is not None: + _LOGGER.debug("Importing yaml single: %s", host) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + results.append((host, result)) + else: + ssdp_entries: list[SsdpServiceInfo] = await ssdp.async_get_discovery_info_by_st( + hass, "upnp:rootdevice" + ) + matches = [ + w + for w in ssdp_entries + if w.ssdp_location + and await YamahaConfigInfo.check_yamaha_ssdp(w.ssdp_location, hass) + ] + for entry in matches: + host = entry.ssdp_headers.get("_host") + _LOGGER.debug("Importing yaml discover: %s", host) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config | {CONF_HOST: host}, ) - else: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) + results.append((host, result)) + + _LOGGER.debug("Importing yaml results: %s", results) + if not results: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_no_discover", + breaks_in_ha_version="2025.6", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_no_discover", + translation_placeholders={"url": ISSUE_URL_PLACEHOLDER}, + ) + + all_successful = True + for host, result in results: + if ( + result.get("type") == FlowResultType.CREATE_ENTRY + or result.get("reason") == "already_configured" + ): + continue + if error := result.get("reason"): + all_successful = False + async_create_issue( + hass, + DOMAIN, + f"deprecated_yaml_import_issue_{host}_{error}", + breaks_in_ha_version="2025.6", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key=f"deprecated_yaml_import_issue_{error}", + translation_placeholders={ + "host": host, + "url": ISSUE_URL_PLACEHOLDER, + }, ) - else: - _LOGGER.error( - "Configuration in configuration.yaml is not supported anymore. " - "Please add this device using the config flow" + if all_successful: + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + issue_domain=DOMAIN, + breaks_in_ha_version="2025.6", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "yamaha", + }, ) # Register Service 'select_scene' diff --git a/homeassistant/components/yamaha/strings.json b/homeassistant/components/yamaha/strings.json index 3cfd4c9ed10db0..84688a6c673e76 100644 --- a/homeassistant/components/yamaha/strings.json +++ b/homeassistant/components/yamaha/strings.json @@ -17,12 +17,20 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "yxc_control_url_missing": "The control URL is not given in the ssdp description." - }, - "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "no_yamaha_device": "This device seems to be no Yamaha Device.", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "yxc_control_url_missing": "The control URL is not given in the ssdp description." + } + }, + "issues": { + "deprecated_yaml_import_issue_no_discover": { + "title": "The Yamaha YAML configuration import failed", + "description": "Configuring Yamaha using YAML is being removed but no receivers were discovered when importing your YAML configuration.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Yamaha YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Yamaha YAML configuration import failed", + "description": "Configuring Yamaha using YAML is being removed but there was a connection error when importing your YAML configuration for host {host}.\n\nEnsure the connection to the receiver works and restart Home Assistant to try again or remove the Yamaha YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } }, "options": { diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py index 8d8329eb704c4f..48e004ff40dedc 100644 --- a/tests/components/yamaha/test_config_flow.py +++ b/tests/components/yamaha/test_config_flow.py @@ -64,6 +64,16 @@ def mock_get_device_info_invalid(): yield +@pytest.fixture +def mock_get_device_info_exception(): + """Mock raising an unexpected Exception.""" + with patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + side_effect=Exception("mocked error"), + ): + yield + + @pytest.fixture def mock_get_device_info_mc_exception(): """Mock raising an unexpected Exception.""" @@ -153,7 +163,7 @@ def mock_empty_discovery_information(): @pytest.fixture(name="config_entry") def mock_config_entry() -> MockConfigEntry: - """Create Onkyo entry in Home Assistant.""" + """Create Yamaha entry in Home Assistant.""" return MockConfigEntry( domain=DOMAIN, title="Yamaha Receiver", @@ -178,8 +188,8 @@ async def test_user_input_device_not_found( result["flow_id"], {"host": "none"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "cannot_connect" async def test_user_input_non_yamaha_device_found( @@ -196,8 +206,8 @@ async def test_user_input_non_yamaha_device_found( {"host": "127.0.0.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "no_yamaha_device"} + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "cannot_connect" async def test_user_input_device_already_existing( @@ -224,7 +234,9 @@ async def test_user_input_device_already_existing( assert result2["reason"] == "already_configured" -async def test_user_input_unknown_error(hass: HomeAssistant) -> None: +async def test_user_input_unknown_error( + hass: HomeAssistant, mock_get_device_info_exception +) -> None: """Test when user specifies an existing device, which does not provide the Yamaha API.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -236,8 +248,8 @@ async def test_user_input_unknown_error(hass: HomeAssistant) -> None: {"host": "127.0.0.1"}, ) - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "unknown" async def test_user_input_device_found( @@ -316,7 +328,7 @@ async def test_ssdp_discovery_failed(hass: HomeAssistant, mock_ssdp_no_yamaha) - async def test_ssdp_discovery_successful_add_device( hass: HomeAssistant, mock_ssdp_yamaha ) -> None: - """Test when the SSDP discovered device is a musiccast device and the user confirms it.""" + """Test when the SSDP discovered device is a yamaha device and the user confirms it.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_SSDP}, diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index e2956edcccf774..0375eaa95fd86c 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -1,5 +1,6 @@ """The tests for the Yamaha Media player platform.""" +from collections.abc import Generator from unittest.mock import MagicMock, PropertyMock, call, patch import pytest @@ -17,8 +18,9 @@ def _create_zone_mock(name, url): zone = MagicMock() zone.ctrl_url = url - zone.surround_programs = [] + zone.surround_programs = list zone.zone = name + zone.model_name = None return zone @@ -36,6 +38,23 @@ def zone_controllers(self): return self._zones +@pytest.fixture(autouse=True) +def silent_ssdp_scanner() -> Generator[None]: + """Start SSDP component and get Scanner, prevent actual SSDP traffic.""" + with ( + patch("homeassistant.components.ssdp.Scanner._async_start_ssdp_listeners"), + patch("homeassistant.components.ssdp.Scanner._async_stop_ssdp_listeners"), + patch("homeassistant.components.ssdp.Scanner.async_scan"), + patch( + "homeassistant.components.ssdp.Server._async_start_upnp_servers", + ), + patch( + "homeassistant.components.ssdp.Server._async_stop_upnp_servers", + ), + ): + yield + + @pytest.fixture(name="main_zone") def main_zone_fixture(): """Mock the main zone.""" From 1ec67e8f34be108064e7444a584f9aedd4730eee Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 29 Nov 2024 10:05:59 +0100 Subject: [PATCH 11/14] #130820 Add config flow to Yamaha - increase test coverage for issue creation --- tests/components/yamaha/test_media_player.py | 144 ++++++++++++++++++- 1 file changed, 139 insertions(+), 5 deletions(-) diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index 0375eaa95fd86c..b372fa598bb714 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -4,12 +4,15 @@ from unittest.mock import MagicMock, PropertyMock, call, patch import pytest +from requests.exceptions import ConnectionError from rxv.ssdp import RxvDetails +from homeassistant.components import ssdp from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.yamaha import media_player as yamaha from homeassistant.components.yamaha.const import DOMAIN -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component CONFIG = {"media_player": {"platform": "yamaha", "host": "127.0.0.1"}} @@ -71,7 +74,7 @@ def device_fixture(main_zone): "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", return_value=RxvDetails( model_name="MC20", - ctrl_url=None, + ctrl_url=device.ctrl_url, unit_desc_url=None, friendly_name=None, serial_number="1234567890", @@ -93,7 +96,7 @@ def device2_fixture(main_zone): "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", return_value=RxvDetails( model_name="AX100", - ctrl_url=None, + ctrl_url=device.ctrl_url, unit_desc_url=None, friendly_name=None, serial_number="0987654321", @@ -103,7 +106,71 @@ def device2_fixture(main_zone): yield device -async def test_setup_host(hass: HomeAssistant, device, device2, main_zone) -> None: +@pytest.fixture +def mock_invalid_discovery_information(): + """Mock that the ssdp scanner returns a useful upnp description.""" + with ( + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", + return_value=True, + ), + patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[ + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", + ssdp_headers={ + "_host": "127.0.0.1", + }, + upnp={ + ssdp.ATTR_UPNP_SERIAL: "1234567890", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + }, + ) + ], + ), + patch( + "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + side_effect=ConnectionError("mocked error"), + ), + ): + yield + + +@pytest.fixture +def mock_valid_discovery_information(): + """Mock that the ssdp scanner returns a useful upnp description.""" + with ( + patch( + "homeassistant.components.ssdp.async_get_discovery_info_by_st", + return_value=[ + ssdp.SsdpServiceInfo( + ssdp_usn="mock_usn", + ssdp_st="mock_st", + ssdp_location="http://127.0.0.1:9000/MediaRenderer/desc.xml", + ssdp_headers={ + "_host": "127.0.0.1", + }, + upnp={ + ssdp.ATTR_UPNP_SERIAL: "1234567890", + ssdp.ATTR_UPNP_MODEL_NAME: "MC20", + }, + ) + ], + ), + ): + yield + + +async def test_setup_host( + hass: HomeAssistant, + device, + device2, + main_zone, + issue_registry: ir.IssueRegistry, +) -> None: """Test set up integration with host.""" assert await async_setup_component(hass, MP_DOMAIN, CONFIG) await hass.async_block_till_done() @@ -142,7 +209,9 @@ async def test_setup_find_errors(hass: HomeAssistant, device, main_zone, error) assert state.state == "off" -async def test_setup_no_host(hass: HomeAssistant, device, main_zone) -> None: +async def test_setup_no_host( + hass: HomeAssistant, device, main_zone, issue_registry: ir.IssueRegistry +) -> None: """Test set up integration without host.""" assert await async_setup_component( hass, MP_DOMAIN, {"media_player": {"platform": "yamaha"}} @@ -153,6 +222,71 @@ async def test_setup_no_host(hass: HomeAssistant, device, main_zone) -> None: assert state is None + assert len(issue_registry.issues) == 2 + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_no_discover" + ) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_no_discover" + + issue2 = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue2 + assert issue2.translation_key == "deprecated_yaml" + assert issue2.issue_domain == DOMAIN + + +async def test_setup_discovery( + hass: HomeAssistant, + device, + main_zone, + mock_valid_discovery_information, + issue_registry: ir.IssueRegistry, +) -> None: + """Test set up integration via discovery.""" + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "yamaha"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("media_player.yamaha_receiver_main_zone") + + assert state is not None + assert state.state == "off" + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" + ) + assert issue + assert issue.translation_key == "deprecated_yaml" + assert issue.issue_domain == DOMAIN + + +async def test_setup_discovery_invalid_host( + hass: HomeAssistant, + mock_invalid_discovery_information, + issue_registry: ir.IssueRegistry, +) -> None: + """Test set up integration via discovery.""" + assert await async_setup_component( + hass, MP_DOMAIN, {"media_player": {"platform": "yamaha"}} + ) + await hass.async_block_till_done() + + state = hass.states.get("media_player.yamaha_receiver_main_zone") + + assert state is None + + assert len(issue_registry.issues) == 1 + issue = issue_registry.async_get_issue( + DOMAIN, "deprecated_yaml_import_issue_127.0.0.1_cannot_connect" + ) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + assert issue.issue_domain == DOMAIN + async def test_enable_output(hass: HomeAssistant, device, main_zone) -> None: """Test enable output service.""" From 1401c7f49104109df693b854ca4d57ddee0e7fd5 Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Fri, 29 Nov 2024 10:13:06 +0100 Subject: [PATCH 12/14] #130820 Add config flow to Yamaha - increase test coverage for issue creation --- homeassistant/components/yamaha/config_flow.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index 8dea1097b95133..dc9fab6bf3f892 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -10,7 +10,6 @@ import rxv import voluptuous as vol -from homeassistant import data_entry_flow from homeassistant.components import ssdp from homeassistant.config_entries import ( ConfigEntry, @@ -135,9 +134,7 @@ async def async_step_ssdp( return await self.async_step_confirm() - async def async_step_confirm( - self, user_input=None - ) -> data_entry_flow.ConfigFlowResult: + async def async_step_confirm(self, user_input=None) -> ConfigFlowResult: """Allow the user to confirm adding the device.""" if user_input is not None: return self.async_create_entry( From 46b50031b53f11807abd7df0f528f79c5fcf8f3d Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Sat, 7 Dec 2024 18:09:03 +0100 Subject: [PATCH 13/14] #130820 Add config flow to Yamaha - handle devices without SSDP and serials --- .../components/yamaha/config_flow.py | 11 ++++--- .../components/yamaha/media_player.py | 22 ++++++------- .../components/yamaha/yamaha_config_info.py | 33 +++++++++++++++++-- tests/components/yamaha/test_config_flow.py | 33 +++++++++++++++++++ 4 files changed, 81 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index dc9fab6bf3f892..5fa342994b0001 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -60,7 +60,6 @@ async def async_step_user( return self._show_setup_form() host = user_input[CONF_HOST] - serial_number = None # Check if device is a Yamaha receiver try: @@ -72,11 +71,13 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") return self.async_abort(reason="unknown") else: - if info is None or (serial_number := info.serial_number) is None: + if info is None: return self.async_abort(reason="cannot_connect") - - await self.async_set_unique_id(serial_number, raise_on_progress=False) - self._abort_if_unique_id_configured() + if (serial_number := info.serial_number) is None: + await self._async_handle_discovery_without_unique_id() + else: + await self.async_set_unique_id(serial_number, raise_on_progress=False) + self._abort_if_unique_id_configured() return self.async_create_entry( title=DEFAULT_NAME, diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index c4b7a8f16ce554..f0c4223844edfb 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -113,6 +113,7 @@ async def async_setup_entry( zctrl, entry.options.get(OPTION_INPUT_SOURCES_IGNORE), entry.options.get(OPTION_INPUT_SOURCES), + entry.entry_id, ) media_players.append(entity) @@ -240,6 +241,7 @@ def __init__( zctrl: RXV, source_ignore: list[str] | None, source_names: dict[str, str] | None, + config_entry_id: str, ) -> None: """Initialize the Yamaha Receiver.""" self.zctrl = zctrl @@ -253,17 +255,15 @@ def __init__( self._play_status = None self._name = name self._zone = zctrl.zone - if self.zctrl.serial_number is not None: - # Since not all receivers will have a serial number and set a unique id - # the default name of the integration may not be changed - # to avoid a breaking change. - self._attr_unique_id = f"{self.zctrl.serial_number}_{self._zone}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=BRAND, - name=name + " " + zctrl.zone, - model=zctrl.model_name, - ) + self._attr_unique_id = ( + f"{self.zctrl.serial_number or config_entry_id}_{self._zone}" + ) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=BRAND, + name=name + " " + zctrl.zone, + model=zctrl.model_name, + ) def update(self) -> None: """Get the latest details from the device.""" diff --git a/homeassistant/components/yamaha/yamaha_config_info.py b/homeassistant/components/yamaha/yamaha_config_info.py index f298a9d64a9a60..0aac9d8975d8fb 100644 --- a/homeassistant/components/yamaha/yamaha_config_info.py +++ b/homeassistant/components/yamaha/yamaha_config_info.py @@ -1,10 +1,17 @@ """Configuration Information for Yamaha.""" -from rxv import ssdp +import logging +from urllib.parse import urlparse + +from requests import RequestException +import rxv +from rxv import RXV, ssdp from rxv.ssdp import RxvDetails from homeassistant.core import HomeAssistant +_LOGGER = logging.getLogger(__name__) + class YamahaConfigInfo: """Check and retrieve configuration Info for Yamaha Receivers.""" @@ -22,4 +29,26 @@ async def get_rxv_details( cls, location: str, hass: HomeAssistant ) -> RxvDetails | None: """Retrieve the serial_number and model from the SSDP description URL.""" - return await hass.async_add_executor_job(ssdp.rxv_details, location) + info: RxvDetails | None = None + try: + info = await hass.async_add_executor_job(ssdp.rxv_details, location) + except RequestException: + _LOGGER.warning( + "Failed to retrieve RXV details from SSDP location: %s", location + ) + if info is None: + # Fallback for devices that do not reply to SSDP or fail to reply on the SSDP Url + # The legacy behaviour is to connect directly to the control Url + # Note that model_name, friendly_name and serial_number will not be known for those devices + ctrl_url: str = ( + f"http://{urlparse(location).hostname}:80/YamahaRemoteControl/ctrl" + ) + result: RXV = await hass.async_add_executor_job(rxv.RXV, ctrl_url) + info = RxvDetails( + result.ctrl_url, + result.unit_desc_url, + result.model_name, + result.friendly_name, + result.serial_number, + ) + return info diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py index 48e004ff40dedc..8bffa5cecb2c96 100644 --- a/tests/components/yamaha/test_config_flow.py +++ b/tests/components/yamaha/test_config_flow.py @@ -84,6 +84,16 @@ def mock_get_device_info_mc_exception(): yield +@pytest.fixture +def mock_get_device_info_mc_ctrl_url(): + """Mock raising an unexpected Exception.""" + with ( + patch("rxv.RXV", return_value=Mock(serial_number=None)), + patch("rxv.ssdp.rxv_details", return_value=None), + ): + yield + + @pytest.fixture def mock_ssdp_yamaha(): """Mock that the SSDP detected device is a Yamaha device.""" @@ -192,6 +202,29 @@ async def test_user_input_device_not_found( assert result2["reason"] == "cannot_connect" +async def test_user_input_device_with_ctrl_url( + hass: HomeAssistant, mock_get_device_info_mc_ctrl_url +) -> None: + """Test when user specifies a non-existing device.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": "127.0.0.1"}, + ) + assert result2["type"] is FlowResultType.CREATE_ENTRY + assert isinstance(result2["result"], ConfigEntry) + assert result2["data"] == { + "host": "127.0.0.1", + "serial": None, + "upnp_description": "http://127.0.0.1:49154/MediaRenderer/desc.xml", + } + + async def test_user_input_non_yamaha_device_found( hass: HomeAssistant, mock_get_device_info_invalid ) -> None: From 1bb0b198e61da9ec250b66c17f1d9a2cdac88d0f Mon Sep 17 00:00:00 2001 From: Marc Vanbrabant Date: Tue, 10 Dec 2024 09:18:53 +0100 Subject: [PATCH 14/14] #130820 Add config flow to Yamaha - moved YamahaConfig class to utils, reverted self._attr_unique_id change in media_player --- homeassistant/components/yamaha/__init__.py | 8 ++- .../components/yamaha/config_flow.py | 9 ++-- .../components/yamaha/media_player.py | 27 +++++----- homeassistant/components/yamaha/utils.py | 46 ++++++++++++++++ .../components/yamaha/yamaha_config_info.py | 54 ------------------- tests/components/yamaha/test_config_flow.py | 12 ++--- tests/components/yamaha/test_media_player.py | 8 +-- 7 files changed, 75 insertions(+), 89 deletions(-) create mode 100644 homeassistant/components/yamaha/utils.py delete mode 100644 homeassistant/components/yamaha/yamaha_config_info.py diff --git a/homeassistant/components/yamaha/__init__.py b/homeassistant/components/yamaha/__init__.py index 82eeae8a4485d0..a6fab45f3204d0 100644 --- a/homeassistant/components/yamaha/__init__.py +++ b/homeassistant/components/yamaha/__init__.py @@ -12,8 +12,8 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant +from . import utils from .const import CONF_SERIAL, CONF_UPNP_DESC, DOMAIN -from .yamaha_config_info import YamahaConfigInfo PLATFORMS = [Platform.MEDIA_PLAYER] @@ -50,11 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data.setdefault(DOMAIN, {}) - rxv_details = await YamahaConfigInfo.get_rxv_details( - entry.data[CONF_UPNP_DESC], hass - ) + rxv_details = await utils.get_rxv_details(entry.data[CONF_UPNP_DESC], hass) entry.runtime_data = await hass.async_add_executor_job( - partial(rxv.RXV, **rxv_details._asdict()) # type: ignore[union-attr] + partial(rxv.RXV, **rxv_details._asdict()) ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/yamaha/config_flow.py b/homeassistant/components/yamaha/config_flow.py index 5fa342994b0001..7618b4580d3c1b 100644 --- a/homeassistant/components/yamaha/config_flow.py +++ b/homeassistant/components/yamaha/config_flow.py @@ -28,7 +28,7 @@ TextSelector, ) -from . import get_upnp_desc +from . import get_upnp_desc, utils from .const import ( CONF_SERIAL, CONF_UPNP_DESC, @@ -37,7 +37,6 @@ OPTION_INPUT_SOURCES, OPTION_INPUT_SOURCES_IGNORE, ) -from .yamaha_config_info import YamahaConfigInfo _LOGGER = logging.getLogger(__name__) @@ -64,7 +63,7 @@ async def async_step_user( # Check if device is a Yamaha receiver try: upnp_desc: str = await get_upnp_desc(self.hass, host) - info = await YamahaConfigInfo.get_rxv_details(upnp_desc, self.hass) + info = await utils.get_rxv_details(upnp_desc, self.hass) except ConnectionError: return self.async_abort(reason="cannot_connect") except Exception: @@ -106,9 +105,7 @@ async def async_step_ssdp( ) -> ConfigFlowResult: """Handle ssdp discoveries.""" assert discovery_info.ssdp_location is not None - if not await YamahaConfigInfo.check_yamaha_ssdp( - discovery_info.ssdp_location, self.hass - ): + if not await utils.check_yamaha_ssdp(discovery_info.ssdp_location, self.hass): return self.async_abort(reason="yxc_control_url_missing") self.serial_number = discovery_info.upnp[ssdp.ATTR_UPNP_SERIAL] self.upnp_description = discovery_info.ssdp_location diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py index f0c4223844edfb..d8c6bba951b0fb 100644 --- a/homeassistant/components/yamaha/media_player.py +++ b/homeassistant/components/yamaha/media_player.py @@ -30,7 +30,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import YamahaConfigInfo +from . import utils from .const import ( BRAND, CONF_SOURCE_IGNORE, @@ -113,7 +113,6 @@ async def async_setup_entry( zctrl, entry.options.get(OPTION_INPUT_SOURCES_IGNORE), entry.options.get(OPTION_INPUT_SOURCES), - entry.entry_id, ) media_players.append(entity) @@ -143,8 +142,7 @@ async def async_setup_platform( matches = [ w for w in ssdp_entries - if w.ssdp_location - and await YamahaConfigInfo.check_yamaha_ssdp(w.ssdp_location, hass) + if w.ssdp_location and await utils.check_yamaha_ssdp(w.ssdp_location, hass) ] for entry in matches: host = entry.ssdp_headers.get("_host") @@ -241,7 +239,6 @@ def __init__( zctrl: RXV, source_ignore: list[str] | None, source_names: dict[str, str] | None, - config_entry_id: str, ) -> None: """Initialize the Yamaha Receiver.""" self.zctrl = zctrl @@ -255,15 +252,17 @@ def __init__( self._play_status = None self._name = name self._zone = zctrl.zone - self._attr_unique_id = ( - f"{self.zctrl.serial_number or config_entry_id}_{self._zone}" - ) - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=BRAND, - name=name + " " + zctrl.zone, - model=zctrl.model_name, - ) + if self.zctrl.serial_number is not None: + # Since not all receivers will have a serial number and set a unique id + # the default name of the integration may not be changed + # to avoid a breaking change. + self._attr_unique_id = f"{self.zctrl.serial_number}_{self._zone}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=BRAND, + name=name + " " + zctrl.zone, + model=zctrl.model_name, + ) def update(self) -> None: """Get the latest details from the device.""" diff --git a/homeassistant/components/yamaha/utils.py b/homeassistant/components/yamaha/utils.py new file mode 100644 index 00000000000000..2272d191cf48b1 --- /dev/null +++ b/homeassistant/components/yamaha/utils.py @@ -0,0 +1,46 @@ +"""Configuration Information for Yamaha.""" + +import logging +from urllib.parse import urlparse + +from requests import RequestException +import rxv +from rxv import RXV, ssdp +from rxv.ssdp import RxvDetails + +from homeassistant.core import HomeAssistant + +_LOGGER = logging.getLogger(__name__) + + +async def check_yamaha_ssdp(location: str, hass: HomeAssistant) -> bool: + """Check if the Yamaha receiver has a valid control URL.""" + details: RxvDetails | None = await get_rxv_details(location, hass) + return (details and details.ctrl_url) is not None + + +async def get_rxv_details(location: str, hass: HomeAssistant) -> RxvDetails: + """Retrieve the serial_number and model from the SSDP description URL.""" + info: RxvDetails | None = None + try: + info = await hass.async_add_executor_job(ssdp.rxv_details, location) + except RequestException: + _LOGGER.warning( + "Failed to retrieve RXV details from SSDP location: %s", location + ) + if info is None: + # Fallback for devices that do not reply to SSDP or fail to reply on the SSDP Url + # The legacy behaviour is to connect directly to the control Url + # Note that model_name, friendly_name and serial_number will not be known for those devices + ctrl_url: str = ( + f"http://{urlparse(location).hostname}:80/YamahaRemoteControl/ctrl" + ) + result: RXV = await hass.async_add_executor_job(rxv.RXV, ctrl_url) + info = RxvDetails( + result.ctrl_url, + result.unit_desc_url, + result.model_name, + result.friendly_name, + result.serial_number, + ) + return info diff --git a/homeassistant/components/yamaha/yamaha_config_info.py b/homeassistant/components/yamaha/yamaha_config_info.py deleted file mode 100644 index 0aac9d8975d8fb..00000000000000 --- a/homeassistant/components/yamaha/yamaha_config_info.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Configuration Information for Yamaha.""" - -import logging -from urllib.parse import urlparse - -from requests import RequestException -import rxv -from rxv import RXV, ssdp -from rxv.ssdp import RxvDetails - -from homeassistant.core import HomeAssistant - -_LOGGER = logging.getLogger(__name__) - - -class YamahaConfigInfo: - """Check and retrieve configuration Info for Yamaha Receivers.""" - - @classmethod - async def check_yamaha_ssdp(cls, location: str, hass: HomeAssistant) -> bool: - """Check if the Yamaha receiver has a valid control URL.""" - details: RxvDetails | None = await YamahaConfigInfo.get_rxv_details( - location, hass - ) - return (details and details.ctrl_url) is not None - - @classmethod - async def get_rxv_details( - cls, location: str, hass: HomeAssistant - ) -> RxvDetails | None: - """Retrieve the serial_number and model from the SSDP description URL.""" - info: RxvDetails | None = None - try: - info = await hass.async_add_executor_job(ssdp.rxv_details, location) - except RequestException: - _LOGGER.warning( - "Failed to retrieve RXV details from SSDP location: %s", location - ) - if info is None: - # Fallback for devices that do not reply to SSDP or fail to reply on the SSDP Url - # The legacy behaviour is to connect directly to the control Url - # Note that model_name, friendly_name and serial_number will not be known for those devices - ctrl_url: str = ( - f"http://{urlparse(location).hostname}:80/YamahaRemoteControl/ctrl" - ) - result: RXV = await hass.async_add_executor_job(rxv.RXV, ctrl_url) - info = RxvDetails( - result.ctrl_url, - result.unit_desc_url, - result.model_name, - result.friendly_name, - result.serial_number, - ) - return info diff --git a/tests/components/yamaha/test_config_flow.py b/tests/components/yamaha/test_config_flow.py index 8bffa5cecb2c96..49e39d31c56a92 100644 --- a/tests/components/yamaha/test_config_flow.py +++ b/tests/components/yamaha/test_config_flow.py @@ -57,7 +57,7 @@ def mock_get_device_info_invalid(): with ( patch("rxv.RXV", return_value=None), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", return_value=None, ), ): @@ -68,7 +68,7 @@ def mock_get_device_info_invalid(): def mock_get_device_info_exception(): """Mock raising an unexpected Exception.""" with patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", side_effect=Exception("mocked error"), ): yield @@ -98,7 +98,7 @@ def mock_get_device_info_mc_ctrl_url(): def mock_ssdp_yamaha(): """Mock that the SSDP detected device is a Yamaha device.""" with patch( - "homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", + "homeassistant.components.yamaha.utils.check_yamaha_ssdp", return_value=True, ): yield @@ -108,7 +108,7 @@ def mock_ssdp_yamaha(): def mock_ssdp_no_yamaha(): """Mock that the SSDP detected device is not a Yamaha device.""" with patch( - "homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", + "homeassistant.components.yamaha.utils.check_yamaha_ssdp", return_value=False, ): yield @@ -136,7 +136,7 @@ def mock_valid_discovery_information(): ], ), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", return_value=RxvDetails( model_name="MC20", ctrl_url=None, @@ -158,7 +158,7 @@ def mock_empty_discovery_information(): return_value=[], ), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", return_value=RxvDetails( model_name="MC20", ctrl_url=None, diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py index b372fa598bb714..d191f26fadff98 100644 --- a/tests/components/yamaha/test_media_player.py +++ b/tests/components/yamaha/test_media_player.py @@ -71,7 +71,7 @@ def device_fixture(main_zone): with ( patch("rxv.RXV", return_value=device), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", return_value=RxvDetails( model_name="MC20", ctrl_url=device.ctrl_url, @@ -93,7 +93,7 @@ def device2_fixture(main_zone): with ( patch("rxv.RXV", return_value=device), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", return_value=RxvDetails( model_name="AX100", ctrl_url=device.ctrl_url, @@ -111,7 +111,7 @@ def mock_invalid_discovery_information(): """Mock that the ssdp scanner returns a useful upnp description.""" with ( patch( - "homeassistant.components.yamaha.YamahaConfigInfo.check_yamaha_ssdp", + "homeassistant.components.yamaha.utils.check_yamaha_ssdp", return_value=True, ), patch( @@ -132,7 +132,7 @@ def mock_invalid_discovery_information(): ], ), patch( - "homeassistant.components.yamaha.YamahaConfigInfo.get_rxv_details", + "homeassistant.components.yamaha.utils.get_rxv_details", side_effect=ConnectionError("mocked error"), ), ):