From 6db5ff98ed4692b8c3ac34a4cefd7ec9c3132e18 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 16 Jun 2020 14:46:39 +0200 Subject: [PATCH] DenonAVR Config Flow (#35255) Co-authored-by: J. Nick Koston Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + homeassistant/components/denonavr/__init__.py | 95 ++- .../components/denonavr/config_flow.py | 256 ++++++++ .../components/denonavr/manifest.json | 43 +- .../components/denonavr/media_player.py | 157 ++--- homeassistant/components/denonavr/receiver.py | 71 +++ .../components/denonavr/strings.json | 48 ++ .../components/denonavr/translations/en.json | 48 ++ .../components/discovery/__init__.py | 2 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 38 ++ requirements_all.txt | 3 +- requirements_test_all.txt | 3 +- tests/components/denonavr/test_config_flow.py | 561 ++++++++++++++++++ .../components/denonavr/test_media_player.py | 83 ++- 15 files changed, 1272 insertions(+), 138 deletions(-) create mode 100644 homeassistant/components/denonavr/config_flow.py create mode 100644 homeassistant/components/denonavr/receiver.py create mode 100644 homeassistant/components/denonavr/strings.json create mode 100644 homeassistant/components/denonavr/translations/en.json create mode 100644 tests/components/denonavr/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 1543b61071ea3..5fb2bdd55bb45 100644 --- a/.coveragerc +++ b/.coveragerc @@ -160,6 +160,7 @@ omit = homeassistant/components/deluge/switch.py homeassistant/components/denon/media_player.py homeassistant/components/denonavr/media_player.py + homeassistant/components/denonavr/receiver.py homeassistant/components/deutsche_bahn/sensor.py homeassistant/components/devolo_home_control/__init__.py homeassistant/components/devolo_home_control/binary_sensor.py diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py index 8877a7dfb3bd9..89c6413d1462a 100644 --- a/homeassistant/components/denonavr/__init__.py +++ b/homeassistant/components/denonavr/__init__.py @@ -1,15 +1,33 @@ """The denonavr component.""" +import logging + import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID -import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries, core +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.dispatcher import dispatcher_send -DOMAIN = "denonavr" +from .config_flow import ( + CONF_SHOW_ALL_SOURCES, + CONF_ZONE2, + CONF_ZONE3, + DEFAULT_SHOW_SOURCES, + DEFAULT_TIMEOUT, + DEFAULT_ZONE2, + DEFAULT_ZONE3, + DOMAIN, +) +from .receiver import ConnectDenonAVR +CONF_RECEIVER = "receiver" +UNDO_UPDATE_LISTENER = "undo_update_listener" SERVICE_GET_COMMAND = "get_command" ATTR_COMMAND = "command" +_LOGGER = logging.getLogger(__name__) + CALL_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids}) GET_COMMAND_SCHEMA = CALL_SCHEMA.extend({vol.Required(ATTR_COMMAND): cv.string}) @@ -19,7 +37,7 @@ } -def setup(hass, config): +def setup(hass: core.HomeAssistant, config: dict): """Set up the denonavr platform.""" def service_handler(service): @@ -33,3 +51,72 @@ def service_handler(service): hass.services.register(DOMAIN, service, service_handler, schema=schema) return True + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up the denonavr components from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + # Connect to receiver + connect_denonavr = ConnectDenonAVR( + hass, + entry.data[CONF_HOST], + DEFAULT_TIMEOUT, + entry.options.get(CONF_SHOW_ALL_SOURCES, DEFAULT_SHOW_SOURCES), + entry.options.get(CONF_ZONE2, DEFAULT_ZONE2), + entry.options.get(CONF_ZONE3, DEFAULT_ZONE3), + ) + if not await connect_denonavr.async_connect_receiver(): + raise ConfigEntryNotReady + receiver = connect_denonavr.receiver + + undo_listener = entry.add_update_listener(update_listener) + + hass.data[DOMAIN][entry.entry_id] = { + CONF_RECEIVER: receiver, + UNDO_UPDATE_LISTENER: undo_listener, + } + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "media_player") + ) + + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_forward_entry_unload( + config_entry, "media_player" + ) + + hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() + + # Remove zone2 and zone3 entities if needed + entity_registry = await er.async_get_registry(hass) + entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + zone2_id = f"{config_entry.unique_id}-Zone2" + zone3_id = f"{config_entry.unique_id}-Zone3" + for entry in entries: + if entry.unique_id == zone2_id and not config_entry.options.get(CONF_ZONE2): + entity_registry.async_remove(entry.entity_id) + _LOGGER.debug("Removing zone2 from DenonAvr") + if entry.unique_id == zone3_id and not config_entry.options.get(CONF_ZONE3): + entity_registry.async_remove(entry.entity_id) + _LOGGER.debug("Removing zone3 from DenonAvr") + + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +async def update_listener( + hass: core.HomeAssistant, config_entry: config_entries.ConfigEntry +): + """Handle options update.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py new file mode 100644 index 0000000000000..595f958ce0181 --- /dev/null +++ b/homeassistant/components/denonavr/config_flow.py @@ -0,0 +1,256 @@ +"""Config flow to configure Denon AVR receivers using their HTTP interface.""" +from functools import partial +import logging +from urllib.parse import urlparse + +import denonavr +from getmac import get_mac_address +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import ssdp +from homeassistant.const import CONF_HOST, CONF_MAC +from homeassistant.core import callback +from homeassistant.helpers.device_registry import format_mac + +from .receiver import ConnectDenonAVR + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "denonavr" + +SUPPORTED_MANUFACTURERS = ["Denon", "DENON", "Marantz"] + +CONF_SHOW_ALL_SOURCES = "show_all_sources" +CONF_ZONE2 = "zone2" +CONF_ZONE3 = "zone3" +CONF_TYPE = "type" +CONF_MODEL = "model" +CONF_MANUFACTURER = "manufacturer" +CONF_SERIAL_NUMBER = "serial_number" + +DEFAULT_SHOW_SOURCES = False +DEFAULT_TIMEOUT = 5 +DEFAULT_ZONE2 = False +DEFAULT_ZONE3 = False + +CONFIG_SCHEMA = vol.Schema({vol.Optional(CONF_HOST): str}) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Options for the component.""" + + def __init__(self, config_entry: config_entries.ConfigEntry): + """Init object.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Manage the options.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + settings_schema = vol.Schema( + { + vol.Optional( + CONF_SHOW_ALL_SOURCES, + default=self.config_entry.options.get( + CONF_SHOW_ALL_SOURCES, DEFAULT_SHOW_SOURCES + ), + ): bool, + vol.Optional( + CONF_ZONE2, + default=self.config_entry.options.get(CONF_ZONE2, DEFAULT_ZONE2), + ): bool, + vol.Optional( + CONF_ZONE3, + default=self.config_entry.options.get(CONF_ZONE3, DEFAULT_ZONE3), + ): bool, + } + ) + + return self.async_show_form(step_id="init", data_schema=settings_schema) + + +class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Denon AVR config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the Denon AVR flow.""" + self.host = None + self.serial_number = None + self.model_name = None + self.timeout = DEFAULT_TIMEOUT + self.show_all_sources = DEFAULT_SHOW_SOURCES + self.zone2 = DEFAULT_ZONE2 + self.zone3 = DEFAULT_ZONE3 + self.d_receivers = [] + + @staticmethod + @callback + def async_get_options_flow(config_entry) -> OptionsFlowHandler: + """Get the options flow.""" + return OptionsFlowHandler(config_entry) + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + # check if IP address is set manually + host = user_input.get(CONF_HOST) + if host: + self.host = host + return await self.async_step_connect() + + # discovery using denonavr library + self.d_receivers = await self.hass.async_add_executor_job(denonavr.discover) + # More than one receiver could be discovered by that method + if len(self.d_receivers) == 1: + self.host = self.d_receivers[0]["host"] + return await self.async_step_connect() + if len(self.d_receivers) > 1: + # show selection form + return await self.async_step_select() + + errors["base"] = "discovery_error" + + return self.async_show_form( + step_id="user", data_schema=CONFIG_SCHEMA, errors=errors + ) + + async def async_step_select(self, user_input=None): + """Handle multiple receivers found.""" + errors = {} + if user_input is not None: + self.host = user_input["select_host"] + return await self.async_step_connect() + + select_scheme = vol.Schema( + { + vol.Required("select_host"): vol.In( + [d_receiver["host"] for d_receiver in self.d_receivers] + ) + } + ) + + return self.async_show_form( + step_id="select", data_schema=select_scheme, errors=errors + ) + + async def async_step_confirm(self, user_input=None): + """Allow the user to confirm adding the device.""" + if user_input is not None: + return await self.async_step_connect() + + return self.async_show_form(step_id="confirm") + + async def async_step_connect(self, user_input=None): + """Connect to the receiver.""" + connect_denonavr = ConnectDenonAVR( + self.hass, + self.host, + self.timeout, + self.show_all_sources, + self.zone2, + self.zone3, + ) + if not await connect_denonavr.async_connect_receiver(): + return self.async_abort(reason="connection_error") + receiver = connect_denonavr.receiver + + mac_address = await self.async_get_mac(self.host) + + if not self.serial_number: + self.serial_number = receiver.serial_number + if not self.model_name: + self.model_name = (receiver.model_name).replace("*", "") + + if self.serial_number is not None: + unique_id = self.construct_unique_id(self.model_name, self.serial_number) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + else: + _LOGGER.error( + "Could not get serial number of host %s, " + "unique_id's will not be available", + self.host, + ) + for entry in self._async_current_entries(): + if entry.data[CONF_HOST] == self.host: + return self.async_abort(reason="already_configured") + + return self.async_create_entry( + title=receiver.name, + data={ + CONF_HOST: self.host, + CONF_MAC: mac_address, + CONF_TYPE: receiver.receiver_type, + CONF_MODEL: self.model_name, + CONF_MANUFACTURER: receiver.manufacturer, + CONF_SERIAL_NUMBER: self.serial_number, + }, + ) + + async def async_step_ssdp(self, discovery_info): + """Handle a discovered Denon AVR. + + This flow is triggered by the SSDP component. It will check if the + host is already configured and delegate to the import step if not. + """ + # Filter out non-Denon AVRs#1 + if ( + discovery_info.get(ssdp.ATTR_UPNP_MANUFACTURER) + not in SUPPORTED_MANUFACTURERS + ): + return self.async_abort(reason="not_denonavr_manufacturer") + + # Check if required information is present to set the unique_id + if ( + ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info + or ssdp.ATTR_UPNP_SERIAL not in discovery_info + ): + return self.async_abort(reason="not_denonavr_missing") + + self.model_name = discovery_info[ssdp.ATTR_UPNP_MODEL_NAME].replace("*", "") + self.serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL] + self.host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname + + unique_id = self.construct_unique_id(self.model_name, self.serial_number) + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured({CONF_HOST: self.host}) + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update( + { + "title_placeholders": { + "name": discovery_info.get(ssdp.ATTR_UPNP_FRIENDLY_NAME, self.host) + } + } + ) + + return await self.async_step_confirm() + + @staticmethod + def construct_unique_id(model_name, serial_number): + """Construct the unique id from the ssdp discovery or user_step.""" + return f"{model_name}-{serial_number}" + + async def async_get_mac(self, host): + """Get the mac address of the DenonAVR receiver.""" + try: + mac_address = await self.hass.async_add_executor_job( + partial(get_mac_address, **{"ip": host}) + ) + if not mac_address: + mac_address = await self.hass.async_add_executor_job( + partial(get_mac_address, **{"hostname": host}) + ) + except Exception as err: # pylint: disable=broad-except + _LOGGER.error("Unable to get mac address: %s", err) + mac_address = None + + if mac_address is not None: + mac_address = format_mac(mac_address) + return mac_address diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index a26bbdd58ab49..4ea844ef06051 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -1,7 +1,46 @@ { "domain": "denonavr", "name": "Denon AVR Network Receivers", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.8.1"], - "codeowners": ["@scarface-4711", "@starkillerOG"] + "requirements": ["denonavr==0.9.3", "getmac==0.8.2"], + "codeowners": ["@scarface-4711", "@starkillerOG"], + "ssdp": [ + { + "manufacturer": "Denon", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "DENON", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "Marantz", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "Denon", + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1" + }, + { + "manufacturer": "DENON", + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1" + }, + { + "manufacturer": "Marantz", + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1" + }, + { + "manufacturer": "Denon", + "deviceType": "urn:schemas-denon-com:device:AiosDevice:1" + }, + { + "manufacturer": "DENON", + "deviceType": "urn:schemas-denon-com:device:AiosDevice:1" + }, + { + "manufacturer": "Marantz", + "deviceType": "urn:schemas-denon-com:device:AiosDevice:1" + } + ] } diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 524e728588bb0..c28b1a4cab5f5 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -1,12 +1,8 @@ """Support for Denon AVR receivers using their HTTP interface.""" -from collections import namedtuple import logging -import denonavr -import voluptuous as vol - -from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEntity +from homeassistant.components.media_player import MediaPlayerEntity from homeassistant.components.media_player.const import ( MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MUSIC, @@ -25,10 +21,7 @@ ) from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_HOST, - CONF_NAME, - CONF_TIMEOUT, - CONF_ZONE, + CONF_MAC, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, STATE_OFF, @@ -36,25 +29,22 @@ STATE_PAUSED, STATE_PLAYING, ) -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import DOMAIN +from . import CONF_RECEIVER +from .config_flow import ( + CONF_MANUFACTURER, + CONF_MODEL, + CONF_SERIAL_NUMBER, + CONF_TYPE, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) ATTR_SOUND_MODE_RAW = "sound_mode_raw" -CONF_INVALID_ZONES_ERR = "Invalid Zone (expected Zone2 or Zone3)" -CONF_SHOW_ALL_SOURCES = "show_all_sources" -CONF_VALID_ZONES = ["Zone2", "Zone3"] -CONF_ZONES = "zones" - -DEFAULT_SHOW_SOURCES = False -DEFAULT_TIMEOUT = 2 - -KEY_DENON_CACHE = "denonavr_hosts" - SUPPORT_DENON = ( SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE @@ -73,99 +63,32 @@ | SUPPORT_PLAY ) -DENON_ZONE_SCHEMA = vol.Schema( - { - vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR), - vol.Optional(CONF_NAME): cv.string, - } -) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES): cv.boolean, - vol.Optional(CONF_ZONES): vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]), - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, - } -) -NewHost = namedtuple("NewHost", ["host", "name"]) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Denon platform.""" - # Initialize list with receivers to be started - receivers = [] - - cache = hass.data.get(KEY_DENON_CACHE) - if cache is None: - cache = hass.data[KEY_DENON_CACHE] = set() - - # Get config option for show_all_sources and timeout - show_all_sources = config[CONF_SHOW_ALL_SOURCES] - timeout = config[CONF_TIMEOUT] - - # Get config option for additional zones - zones = config.get(CONF_ZONES) - if zones is not None: - add_zones = {} - for entry in zones: - add_zones[entry[CONF_ZONE]] = entry.get(CONF_NAME) - else: - add_zones = None - - # Start assignment of host and name - new_hosts = [] - # 1. option: manual setting - if config.get(CONF_HOST) is not None: - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - new_hosts.append(NewHost(host=host, name=name)) - - # 2. option: discovery using netdisco - if discovery_info is not None: - host = discovery_info.get("host") - name = discovery_info.get("name") - new_hosts.append(NewHost(host=host, name=name)) - - # 3. option: discovery using denonavr library - if config.get(CONF_HOST) is None and discovery_info is None: - d_receivers = denonavr.discover() - # More than one receiver could be discovered by that method - for d_receiver in d_receivers: - host = d_receiver["host"] - name = d_receiver["friendlyName"] - new_hosts.append(NewHost(host=host, name=name)) - - for entry in new_hosts: - # Check if host not in cache, append it and save for later - # starting - if entry.host not in cache: - new_device = denonavr.DenonAVR( - host=entry.host, - name=entry.name, - show_all_inputs=show_all_sources, - timeout=timeout, - add_zones=add_zones, - ) - for new_zone in new_device.zones.values(): - receivers.append(DenonDevice(new_zone)) - cache.add(host) - _LOGGER.info("Denon receiver at host %s initialized", host) - - # Add all freshly discovered receivers - if receivers: - add_entities(receivers) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the DenonAVR receiver from a config entry.""" + entities = [] + receiver = hass.data[DOMAIN][config_entry.entry_id][CONF_RECEIVER] + for receiver_zone in receiver.zones.values(): + if config_entry.data[CONF_SERIAL_NUMBER] is not None: + unique_id = f"{config_entry.unique_id}-{receiver_zone.zone}" + else: + unique_id = None + entities.append(DenonDevice(receiver_zone, unique_id, config_entry)) + _LOGGER.debug( + "%s receiver at host %s initialized", receiver.manufacturer, receiver.host + ) + async_add_entities(entities) class DenonDevice(MediaPlayerEntity): """Representation of a Denon Media Player Device.""" - def __init__(self, receiver): + def __init__(self, receiver, unique_id, config_entry): """Initialize the device.""" self._receiver = receiver self._name = self._receiver.name + self._unique_id = unique_id + self._config_entry = config_entry self._muted = self._receiver.muted self._volume = self._receiver.volume self._current_source = self._receiver.input_func @@ -237,6 +160,30 @@ def update(self): self._sound_mode = self._receiver.sound_mode self._sound_mode_raw = self._receiver.sound_mode_raw + @property + def unique_id(self): + """Return the unique id of the zone.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info of the receiver.""" + if self._config_entry.data[CONF_SERIAL_NUMBER] is None: + return None + + device_info = { + "identifiers": {(DOMAIN, self._config_entry.unique_id)}, + "manufacturer": self._config_entry.data[CONF_MANUFACTURER], + "name": self._config_entry.title, + "model": f"{self._config_entry.data[CONF_MODEL]}-{self._config_entry.data[CONF_TYPE]}", + } + if self._config_entry.data[CONF_MAC] is not None: + device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, self._config_entry.data[CONF_MAC]) + } + + return device_info + @property def name(self): """Return the name of the device.""" diff --git a/homeassistant/components/denonavr/receiver.py b/homeassistant/components/denonavr/receiver.py new file mode 100644 index 0000000000000..557427c8c416a --- /dev/null +++ b/homeassistant/components/denonavr/receiver.py @@ -0,0 +1,71 @@ +"""Code to handle a DenonAVR receiver.""" +import logging + +import denonavr + +_LOGGER = logging.getLogger(__name__) + + +class ConnectDenonAVR: + """Class to async connect to a DenonAVR receiver.""" + + def __init__(self, hass, host, timeout, show_all_inputs, zone2, zone3): + """Initialize the class.""" + self._hass = hass + self._receiver = None + self._host = host + self._show_all_inputs = show_all_inputs + self._timeout = timeout + + self._zones = {} + if zone2: + self._zones["Zone2"] = None + if zone3: + self._zones["Zone3"] = None + + @property + def receiver(self): + """Return the class containing all connections to the receiver.""" + return self._receiver + + async def async_connect_receiver(self): + """Connect to the DenonAVR receiver.""" + if not await self._hass.async_add_executor_job(self.init_receiver_class): + return False + + if ( + self._receiver.manufacturer is None + or self._receiver.name is None + or self._receiver.model_name is None + or self._receiver.receiver_type is None + ): + return False + + _LOGGER.debug( + "%s receiver %s at host %s connected, model %s, serial %s, type %s", + self._receiver.manufacturer, + self._receiver.name, + self._receiver.host, + self._receiver.model_name, + self._receiver.serial_number, + self._receiver.receiver_type, + ) + + return True + + def init_receiver_class(self): + """Initialize the DenonAVR class in a way that can called by async_add_executor_job.""" + try: + self._receiver = denonavr.DenonAVR( + host=self._host, + show_all_inputs=self._show_all_inputs, + timeout=self._timeout, + add_zones=self._zones, + ) + except ConnectionError: + _LOGGER.error( + "ConnectionError during setup of denonavr with host %s", self._host + ) + return False + + return True diff --git a/homeassistant/components/denonavr/strings.json b/homeassistant/components/denonavr/strings.json new file mode 100644 index 0000000000000..b01782adf3247 --- /dev/null +++ b/homeassistant/components/denonavr/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "flow_title": "Denon AVR Network Receiver: {name}", + "step": { + "user": { + "title": "Denon AVR Network Receivers", + "description": "Connect to your receiver, if the IP address is not set, auto-discovery is used", + "data": { + "host": "IP address" + } + }, + "confirm": { + "title": "Denon AVR Network Receivers", + "description": "Please confirm adding the receiver" + }, + "select": { + "title": "Select the receiver that you wish to connect", + "description": "Run the setup again if you want to connect additional receivers", + "data": { + "select_host": "Receiver IP" + } + } + }, + "error": { + "discovery_error": "Failed to discover a Denon AVR Network Receiver" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "Config flow for this Denon AVR is already in progress", + "connection_error": "Failed to connect, please try again", + "not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manafucturer did not match", + "not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete" + } + }, + "options": { + "step": { + "init": { + "title": "Denon AVR Network Receivers", + "description": "Specify optional settings", + "data": { + "show_all_sources": "Show all sources", + "zone2": "Set up Zone 2", + "zone3": "Set up Zone 3" + } + } + } + } +} diff --git a/homeassistant/components/denonavr/translations/en.json b/homeassistant/components/denonavr/translations/en.json new file mode 100644 index 0000000000000..d5d3d54cc1c15 --- /dev/null +++ b/homeassistant/components/denonavr/translations/en.json @@ -0,0 +1,48 @@ +{ + "config": { + "flow_title": "Denon AVR Network Receiver: {name}", + "step": { + "user": { + "title": "Denon AVR Network Receivers", + "description": "Connect to your receiver, if the IP address is not set, auto-discovery is used", + "data": { + "host": "IP address" + } + }, + "confirm": { + "title": "Denon AVR Network Receivers", + "description": "Please confirm adding the receiver" + }, + "select": { + "title": "Select the receiver that you wish to connect", + "description": "Run the setup again if you want to connect additional receivers", + "data": { + "select_host": "Receiver IP" + } + } + }, + "error": { + "discovery_error": "Failed to discover a Denon AVR Network Receiver" + }, + "abort": { + "already_configured": "Device is already configured", + "already_in_progress": "Config flow for this Denon AVR is already in progress", + "connection_error": "Failed to connect, please try again", + "not_denonavr_manufacturer": "Not a Denon AVR Network Receiver, discovered manafucturer did not match", + "not_denonavr_missing": "Not a Denon AVR Network Receiver, discovery information not complete" + } + }, + "options": { + "step": { + "init": { + "title": "Denon AVR Network Receivers", + "description": "Specify optional settings", + "data": { + "show_all_sources": "Show all sources", + "zone2": "Set up Zone 2", + "zone3": "Set up Zone 3" + } + } + } + } +} diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index afcf8cc341db9..d6462e2d259c2 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -66,7 +66,6 @@ SERVICE_YEELIGHT: ("yeelight", None), "yamaha": ("media_player", "yamaha"), "logitech_mediaserver": ("media_player", "squeezebox"), - "denonavr": ("media_player", "denonavr"), "frontier_silicon": ("media_player", "frontier_silicon"), "openhome": ("media_player", "openhome"), "bose_soundtouch": ("media_player", "soundtouch"), @@ -82,6 +81,7 @@ MIGRATED_SERVICE_HANDLERS = [ "axis", "deconz", + "denonavr", "esphome", "google_cast", SERVICE_HEOS, diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 644daf61c3209..1a95a37c27893 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -31,6 +31,7 @@ "coronavirus", "daikin", "deconz", + "denonavr", "devolo_home_control", "dialogflow", "directv", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 270257f14f955..1cbade276fedd 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -17,6 +17,44 @@ "manufacturer": "Royal Philips Electronics" } ], + "denonavr": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Denon" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "DENON" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Marantz" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", + "manufacturer": "Denon" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", + "manufacturer": "DENON" + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", + "manufacturer": "Marantz" + }, + { + "deviceType": "urn:schemas-denon-com:device:AiosDevice:1", + "manufacturer": "Denon" + }, + { + "deviceType": "urn:schemas-denon-com:device:AiosDevice:1", + "manufacturer": "DENON" + }, + { + "deviceType": "urn:schemas-denon-com:device:AiosDevice:1", + "manufacturer": "Marantz" + } + ], "directv": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 2f28a1200989a..d962f95b6f7e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -472,7 +472,7 @@ defusedxml==0.6.0 deluge-client==1.7.1 # homeassistant.components.denonavr -denonavr==0.8.1 +denonavr==0.9.3 # homeassistant.components.devolo_home_control devolo-home-control-api==0.11.0 @@ -634,6 +634,7 @@ georss_ign_sismologia_client==0.2 # homeassistant.components.qld_bushfire georss_qld_bushfire_alert_client==0.3 +# homeassistant.components.denonavr # homeassistant.components.huawei_lte # homeassistant.components.kef # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 68655db8848af..527d964ebf25d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -215,7 +215,7 @@ datapoint==0.9.5 defusedxml==0.6.0 # homeassistant.components.denonavr -denonavr==0.8.1 +denonavr==0.9.3 # homeassistant.components.devolo_home_control devolo-home-control-api==0.11.0 @@ -281,6 +281,7 @@ georss_ign_sismologia_client==0.2 # homeassistant.components.qld_bushfire georss_qld_bushfire_alert_client==0.3 +# homeassistant.components.denonavr # homeassistant.components.huawei_lte # homeassistant.components.kef # homeassistant.components.minecraft_server diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py new file mode 100644 index 0000000000000..d7ab51ff02914 --- /dev/null +++ b/tests/components/denonavr/test_config_flow.py @@ -0,0 +1,561 @@ +"""Test the DenonAVR config flow.""" +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import ssdp +from homeassistant.components.denonavr.config_flow import ( + CONF_MANUFACTURER, + CONF_MODEL, + CONF_SERIAL_NUMBER, + CONF_SHOW_ALL_SOURCES, + CONF_TYPE, + CONF_ZONE2, + CONF_ZONE3, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_MAC + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +TEST_HOST = "1.2.3.4" +TEST_MAC = "ab:cd:ef:gh" +TEST_HOST2 = "5.6.7.8" +TEST_NAME = "Test_Receiver" +TEST_MODEL = "model5" +TEST_RECEIVER_TYPE = "avr-x" +TEST_SERIALNUMBER = "123456789" +TEST_MANUFACTURER = "Denon" +TEST_SSDP_LOCATION = f"http://{TEST_HOST}/" +TEST_UNIQUE_ID = f"{TEST_MODEL}-{TEST_SERIALNUMBER}" +TEST_DISCOVER_1_RECEIVER = [{CONF_HOST: TEST_HOST}] +TEST_DISCOVER_2_RECEIVER = [{CONF_HOST: TEST_HOST}, {CONF_HOST: TEST_HOST2}] + + +@pytest.fixture(name="denonavr_connect", autouse=True) +def denonavr_connect_fixture(): + """Mock denonavr connection and entry setup.""" + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR._update_input_func_list", + return_value=True, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR._get_receiver_name", + return_value=TEST_NAME, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR._get_support_sound_mode", + return_value=True, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR._update_avr_2016", + return_value=True, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR._update_avr", + return_value=True, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.get_device_info", + return_value=True, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.name", TEST_NAME, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.model_name", + TEST_MODEL, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.serial_number", + TEST_SERIALNUMBER, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.manufacturer", + TEST_MANUFACTURER, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.receiver_type", + TEST_RECEIVER_TYPE, + ), patch( + "homeassistant.components.denonavr.config_flow.get_mac_address", + return_value=TEST_MAC, + ), patch( + "homeassistant.components.denonavr.async_setup_entry", return_value=True + ): + yield + + +async def test_config_flow_manual_host_success(hass): + """ + Successful flow manually initialized by the user. + + Host specified. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + } + + +async def test_config_flow_manual_discover_1_success(hass): + """ + Successful flow manually initialized by the user. + + Without the host specified and 1 receiver discovered. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.config_flow.denonavr.ssdp.identify_denonavr_receivers", + return_value=TEST_DISCOVER_1_RECEIVER, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + } + + +async def test_config_flow_manual_discover_2_success(hass): + """ + Successful flow manually initialized by the user. + + Without the host specified and 2 receiver discovered. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.config_flow.denonavr.ssdp.identify_denonavr_receivers", + return_value=TEST_DISCOVER_2_RECEIVER, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result["type"] == "form" + assert result["step_id"] == "select" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"select_host": TEST_HOST2}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST2, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + } + + +async def test_config_flow_manual_discover_error(hass): + """ + Failed flow manually initialized by the user. + + Without the host specified and no receiver discovered. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.config_flow.denonavr.ssdp.identify_denonavr_receivers", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "discovery_error"} + + +async def test_config_flow_manual_host_no_serial(hass): + """ + Successful flow manually initialized by the user. + + Host specified and an error getting the serial number. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.serial_number", + None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: None, + } + + +async def test_config_flow_manual_host_no_mac(hass): + """ + Successful flow manually initialized by the user. + + Host specified and an error getting the mac address. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.config_flow.get_mac_address", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: None, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + } + + +async def test_config_flow_manual_host_no_serial_no_mac(hass): + """ + Successful flow manually initialized by the user. + + Host specified and an error getting the serial number and mac address. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.serial_number", + None, + ), patch( + "homeassistant.components.denonavr.config_flow.get_mac_address", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: None, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: None, + } + + +async def test_config_flow_manual_host_no_serial_no_mac_exception(hass): + """ + Successful flow manually initialized by the user. + + Host specified and an error getting the serial number and exception getting mac address. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.serial_number", + None, + ), patch( + "homeassistant.components.denonavr.config_flow.get_mac_address", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: None, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: None, + } + + +async def test_config_flow_manual_host_connection_error(hass): + """ + Failed flow manually initialized by the user. + + Host specified and a connection error. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.get_device_info", + side_effect=ConnectionError, + ), patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.receiver_type", + None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "connection_error" + + +async def test_config_flow_manual_host_no_device_info(hass): + """ + Failed flow manually initialized by the user. + + Host specified and no device info (due to receiver power off). + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.receiver_type", + None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "connection_error" + + +async def test_config_flow_ssdp(hass): + """Successful flow initialized by ssdp discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {},) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + } + + +async def test_config_flow_ssdp_not_denon(hass): + """ + Failed flow initialized by ssdp discovery. + + Not supported manufacturer. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_UPNP_MANUFACTURER: "NotSupported", + ssdp.ATTR_UPNP_MODEL_NAME: TEST_MODEL, + ssdp.ATTR_UPNP_SERIAL: TEST_SERIALNUMBER, + ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_denonavr_manufacturer" + + +async def test_config_flow_ssdp_missing_info(hass): + """ + Failed flow initialized by ssdp discovery. + + Missing information. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data={ + ssdp.ATTR_UPNP_MANUFACTURER: TEST_MANUFACTURER, + ssdp.ATTR_SSDP_LOCATION: TEST_SSDP_LOCATION, + }, + ) + + assert result["type"] == "abort" + assert result["reason"] == "not_denonavr_missing" + + +async def test_options_flow(hass): + """Test specifying non default settings using options flow.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_UNIQUE_ID, + data={ + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + }, + title=TEST_NAME, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_SHOW_ALL_SOURCES: True, CONF_ZONE2: True, CONF_ZONE3: True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == { + CONF_SHOW_ALL_SOURCES: True, + CONF_ZONE2: True, + CONF_ZONE3: True, + } + + +async def test_config_flow_manual_host_no_serial_double_config(hass): + """ + Failed flow manually initialized by the user twice. + + Host specified and an error getting the serial number. + """ + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.serial_number", + None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == TEST_NAME + assert result["data"] == { + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: None, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.serial_number", + None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_HOST: TEST_HOST}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/denonavr/test_media_player.py b/tests/components/denonavr/test_media_player.py index 1547391a33923..980ad758c80d7 100644 --- a/tests/components/denonavr/test_media_player.py +++ b/tests/components/denonavr/test_media_player.py @@ -1,57 +1,92 @@ """The tests for the denonavr media player platform.""" +from unittest.mock import patch + import pytest from homeassistant.components import media_player -from homeassistant.components.denonavr import ATTR_COMMAND, DOMAIN, SERVICE_GET_COMMAND -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PLATFORM -from homeassistant.setup import async_setup_component +from homeassistant.components.denonavr import ATTR_COMMAND, SERVICE_GET_COMMAND +from homeassistant.components.denonavr.config_flow import ( + CONF_MANUFACTURER, + CONF_MODEL, + CONF_SERIAL_NUMBER, + CONF_TYPE, + DOMAIN, +) +from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_MAC -from tests.async_mock import patch +from tests.common import MockConfigEntry -NAME = "fake" -ENTITY_ID = f"{media_player.DOMAIN}.{NAME}" +TEST_HOST = "1.2.3.4" +TEST_MAC = "ab:cd:ef:gh" +TEST_NAME = "Test_Receiver" +TEST_MODEL = "model5" +TEST_SERIALNUMBER = "123456789" +TEST_MANUFACTURER = "Denon" +TEST_RECEIVER_TYPE = "avr-x" +TEST_ZONE = "Main" +TEST_UNIQUE_ID = f"{TEST_MODEL}-{TEST_SERIALNUMBER}" +TEST_TIMEOUT = 2 +TEST_SHOW_ALL_SOURCES = False +TEST_ZONE2 = False +TEST_ZONE3 = False +ENTITY_ID = f"{media_player.DOMAIN}.{TEST_NAME}" @pytest.fixture(name="client") def client_fixture(): """Patch of client library for tests.""" with patch( - "homeassistant.components.denonavr.media_player.denonavr.DenonAVR", - autospec=True, + "homeassistant.components.denonavr.receiver.denonavr.DenonAVR", autospec=True, ) as mock_client_class, patch( - "homeassistant.components.denonavr.media_player.denonavr.discover" + "homeassistant.components.denonavr.receiver.denonavr.discover" ): - mock_client_class.return_value.name = NAME + mock_client_class.return_value.name = TEST_NAME + mock_client_class.return_value.model_name = TEST_MODEL + mock_client_class.return_value.serial_number = TEST_SERIALNUMBER + mock_client_class.return_value.manufacturer = TEST_MANUFACTURER + mock_client_class.return_value.receiver_type = TEST_RECEIVER_TYPE + mock_client_class.return_value.zone = TEST_ZONE + mock_client_class.return_value.input_func_list = [] + mock_client_class.return_value.sound_mode_list = [] mock_client_class.return_value.zones = {"Main": mock_client_class.return_value} yield mock_client_class.return_value async def setup_denonavr(hass): - """Initialize webostv and media_player for tests.""" - assert await async_setup_component( - hass, - media_player.DOMAIN, - { - media_player.DOMAIN: { - CONF_PLATFORM: "denonavr", - CONF_HOST: "fake", - CONF_NAME: NAME, - } - }, + """Initialize media_player for tests.""" + entry_data = { + CONF_HOST: TEST_HOST, + CONF_MAC: TEST_MAC, + CONF_MODEL: TEST_MODEL, + CONF_TYPE: TEST_RECEIVER_TYPE, + CONF_MANUFACTURER: TEST_MANUFACTURER, + CONF_SERIAL_NUMBER: TEST_SERIALNUMBER, + } + + mock_entry = MockConfigEntry( + domain=DOMAIN, unique_id=TEST_UNIQUE_ID, data=entry_data, ) + + mock_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + state = hass.states.get(ENTITY_ID) + + assert state + assert state.name == TEST_NAME + async def test_get_command(hass, client): """Test generic command functionality.""" - await setup_denonavr(hass) data = { ATTR_ENTITY_ID: ENTITY_ID, - ATTR_COMMAND: "test", + ATTR_COMMAND: "test_command", } await hass.services.async_call(DOMAIN, SERVICE_GET_COMMAND, data) await hass.async_block_till_done() - client.send_get_command.assert_called_with("test") + client.send_get_command.assert_called_with("test_command")