From 39d781905de5bdce7325092427fc81969b57d4e2 Mon Sep 17 00:00:00 2001 From: Tomer Shemesh Date: Wed, 18 Dec 2024 04:21:37 -0500 Subject: [PATCH] Add ssdp discovery to Onkyo (#131066) --- CODEOWNERS | 4 +- homeassistant/components/onkyo/config_flow.py | 45 ++++++ homeassistant/components/onkyo/manifest.json | 42 ++++- homeassistant/generated/ssdp.py | 38 +++++ tests/components/onkyo/test_config_flow.py | 147 ++++++++++++++++++ 5 files changed, 272 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f1c6aa4aea5..8effcc49336 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1066,8 +1066,8 @@ build.json @home-assistant/supervisor /tests/components/ondilo_ico/ @JeromeHXP /homeassistant/components/onewire/ @garbled1 @epenet /tests/components/onewire/ @garbled1 @epenet -/homeassistant/components/onkyo/ @arturpragacz -/tests/components/onkyo/ @arturpragacz +/homeassistant/components/onkyo/ @arturpragacz @eclair4151 +/tests/components/onkyo/ @arturpragacz @eclair4151 /homeassistant/components/onvif/ @hunterjm /tests/components/onvif/ @hunterjm /homeassistant/components/open_meteo/ @frenck diff --git a/homeassistant/components/onkyo/config_flow.py b/homeassistant/components/onkyo/config_flow.py index a8ced6fae64..a484b3aaa04 100644 --- a/homeassistant/components/onkyo/config_flow.py +++ b/homeassistant/components/onkyo/config_flow.py @@ -4,7 +4,9 @@ import logging from typing import Any import voluptuous as vol +from yarl import URL +from homeassistant.components import ssdp from homeassistant.config_entries import ( SOURCE_RECONFIGURE, ConfigEntry, @@ -165,6 +167,49 @@ class OnkyoConfigFlow(ConfigFlow, domain=DOMAIN): ), ) + async def async_step_ssdp( + self, discovery_info: ssdp.SsdpServiceInfo + ) -> ConfigFlowResult: + """Handle flow initialized by SSDP discovery.""" + _LOGGER.debug("Config flow start ssdp: %s", discovery_info) + + if udn := discovery_info.ssdp_udn: + udn_parts = udn.split(":") + if len(udn_parts) == 2: + uuid = udn_parts[1] + last_uuid_section = uuid.split("-")[-1].upper() + await self.async_set_unique_id(last_uuid_section) + self._abort_if_unique_id_configured() + + if discovery_info.ssdp_location is None: + _LOGGER.error("SSDP location is None") + return self.async_abort(reason="unknown") + + host = URL(discovery_info.ssdp_location).host + + if host is None: + _LOGGER.error("SSDP host is None") + return self.async_abort(reason="unknown") + + try: + info = await async_interview(host) + except OSError: + _LOGGER.exception("Unexpected exception interviewing host %s", host) + return self.async_abort(reason="unknown") + + if info is None: + _LOGGER.debug("SSDP eiscp is None: %s", host) + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(info.identifier) + self._abort_if_unique_id_configured(updates={CONF_HOST: info.host}) + + self._receiver_info = info + + title_string = f"{info.model_name} ({info.host})" + self.context["title_placeholders"] = {"name": title_string} + return await self.async_step_configure_receiver() + async def async_step_configure_receiver( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json index 0e75404b3eb..6f37fb61b44 100644 --- a/homeassistant/components/onkyo/manifest.json +++ b/homeassistant/components/onkyo/manifest.json @@ -1,11 +1,49 @@ { "domain": "onkyo", "name": "Onkyo", - "codeowners": ["@arturpragacz"], + "codeowners": ["@arturpragacz", "@eclair4151"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/onkyo", "integration_type": "device", "iot_class": "local_push", "loggers": ["pyeiscp"], - "requirements": ["pyeiscp==0.0.7"] + "requirements": ["pyeiscp==0.0.7"], + "ssdp": [ + { + "manufacturer": "ONKYO", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "ONKYO", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "manufacturer": "ONKYO", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "manufacturer": "Onkyo & Pioneer Corporation", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "Onkyo & Pioneer Corporation", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "manufacturer": "Onkyo & Pioneer Corporation", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3" + }, + { + "manufacturer": "Pioneer", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1" + }, + { + "manufacturer": "Pioneer", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2" + }, + { + "manufacturer": "Pioneer", + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3" + } + ] } diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 9ed65bab868..89d1aa30cb8 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -224,6 +224,44 @@ SSDP = { "manufacturer": "The OctoPrint Project", }, ], + "onkyo": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "ONKYO", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "ONKYO", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "manufacturer": "ONKYO", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Onkyo & Pioneer Corporation", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Onkyo & Pioneer Corporation", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "manufacturer": "Onkyo & Pioneer Corporation", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", + "manufacturer": "Pioneer", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:2", + "manufacturer": "Pioneer", + }, + { + "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:3", + "manufacturer": "Pioneer", + }, + ], "openhome": [ { "st": "urn:av-openhome-org:service:Product:1", diff --git a/tests/components/onkyo/test_config_flow.py b/tests/components/onkyo/test_config_flow.py index 1ee0bfdf9c5..f619127d9b9 100644 --- a/tests/components/onkyo/test_config_flow.py +++ b/tests/components/onkyo/test_config_flow.py @@ -6,6 +6,7 @@ from unittest.mock import patch import pytest from homeassistant import config_entries +from homeassistant.components import ssdp from homeassistant.components.onkyo import InputSource from homeassistant.components.onkyo.config_flow import OnkyoConfigFlow from homeassistant.components.onkyo.const import ( @@ -83,6 +84,35 @@ async def test_manual_invalid_host(hass: HomeAssistant, stub_mock_discovery) -> assert host_result["errors"]["base"] == "cannot_connect" +async def test_ssdp_discovery_already_configured( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with already configured device.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.1.100"}, + unique_id="id1", + ) + config_entry.add_to_hass(hass) + + discovery_info = ssdp.SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_manual_valid_host_unexpected_error( hass: HomeAssistant, empty_mock_discovery ) -> None: @@ -198,6 +228,123 @@ async def test_discovery_with_one_selected(hass: HomeAssistant) -> None: assert select_result["description_placeholders"]["name"] == "type 42 (host 42)" +async def test_ssdp_discovery_success( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with valid host.""" + discovery_info = ssdp.SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_udn="uuid:00000000-0000-0000-0000-000000000000", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "configure_receiver" + + select_result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"volume_resolution": 200, "input_sources": ["TV"]}, + ) + + assert select_result["type"] is FlowResultType.CREATE_ENTRY + assert select_result["data"]["host"] == "192.168.1.100" + assert select_result["result"].unique_id == "id1" + + +async def test_ssdp_discovery_host_info_error(hass: HomeAssistant) -> None: + """Test SSDP discovery with host info error.""" + discovery_info = ssdp.SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + with patch( + "homeassistant.components.onkyo.receiver.pyeiscp.Connection.discover", + side_effect=OSError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_ssdp_discovery_host_none_info( + hass: HomeAssistant, stub_mock_discovery +) -> None: + """Test SSDP discovery with host info error.""" + discovery_info = ssdp.SsdpServiceInfo( + ssdp_location="http://192.168.1.100:8080", + upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_ssdp_discovery_no_location( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with no location.""" + discovery_info = ssdp.SsdpServiceInfo( + ssdp_location=None, + upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_ssdp_discovery_no_host( + hass: HomeAssistant, default_mock_discovery +) -> None: + """Test SSDP discovery with no host.""" + discovery_info = ssdp.SsdpServiceInfo( + ssdp_location="http://", + upnp={ssdp.ATTR_UPNP_FRIENDLY_NAME: "Onkyo Receiver"}, + ssdp_usn="uuid:mock_usn", + ssdp_st="mock_st", + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=discovery_info, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "unknown" + + async def test_configure_empty_source_list( hass: HomeAssistant, default_mock_discovery ) -> None: