diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index e2d3e2522dc..b6cda10dcb7 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -24,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import selector from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from .const import DOMAIN, ENTRY_TITLE from .coordinator import HeosConfigEntry @@ -142,51 +143,16 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): if TYPE_CHECKING: assert discovery_info.ssdp_location - entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) hostname = urlparse(discovery_info.ssdp_location).hostname assert hostname is not None - # Abort early when discovery is ignored or host is part of the current system - if entry and ( - entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) - ): - return self.async_abort(reason="single_instance_allowed") + return await self._async_handle_discovered(hostname) - # Connect to discovered host and get system information - heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) - try: - await heos.connect() - system_info = await heos.get_system_info() - except HeosError as error: - _LOGGER.debug( - "Failed to retrieve system information from discovered HEOS device %s", - hostname, - exc_info=error, - ) - return self.async_abort(reason="cannot_connect") - finally: - await heos.disconnect() - - # Select the preferred host, if available - if system_info.preferred_hosts: - hostname = system_info.preferred_hosts[0].ip_address - - # Move to confirmation when not configured - if entry is None: - self._discovered_host = hostname - return await self.async_step_confirm_discovery() - - # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload - if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: - _LOGGER.debug( - "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname - ) - return self.async_update_reload_and_abort( - entry, - data_updates={CONF_HOST: hostname}, - reason="reconfigure_successful", - ) - return self.async_abort(reason="single_instance_allowed") + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + return await self._async_handle_discovered(discovery_info.host) async def async_step_confirm_discovery( self, user_input: dict[str, Any] | None = None @@ -267,6 +233,50 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN): ), ) + async def _async_handle_discovered(self, hostname: str) -> ConfigFlowResult: + entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN) + # Abort early when discovery is ignored or host is part of the current system + if entry and ( + entry.source == SOURCE_IGNORE or hostname in _get_current_hosts(entry) + ): + return self.async_abort(reason="single_instance_allowed") + + # Connect to discovered host and get system information + heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) + try: + await heos.connect() + system_info = await heos.get_system_info() + except HeosError as error: + _LOGGER.debug( + "Failed to retrieve system information from discovered HEOS device %s", + hostname, + exc_info=error, + ) + return self.async_abort(reason="cannot_connect") + finally: + await heos.disconnect() + + # Select the preferred host, if available + if system_info.preferred_hosts and system_info.preferred_hosts[0].ip_address: + hostname = system_info.preferred_hosts[0].ip_address + + # Move to confirmation when not configured + if entry is None: + self._discovered_host = hostname + return await self.async_step_confirm_discovery() + + # Only update if the configured host isn't part of the discovered hosts to ensure new players that come online don't trigger a reload + if entry.data[CONF_HOST] not in [host.ip_address for host in system_info.hosts]: + _LOGGER.debug( + "Updated host %s to discovered host %s", entry.data[CONF_HOST], hostname + ) + return self.async_update_reload_and_abort( + entry, + data_updates={CONF_HOST: hostname}, + reason="reconfigure_successful", + ) + return self.async_abort(reason="single_instance_allowed") + class HeosOptionsFlowHandler(OptionsFlow): """Define HEOS options flow.""" diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json index 8a88913456d..99cedf56f1f 100644 --- a/homeassistant/components/heos/manifest.json +++ b/homeassistant/components/heos/manifest.json @@ -13,5 +13,6 @@ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" } - ] + ], + "zeroconf": ["_heos-audio._tcp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 274fafa51cf..47522a69c41 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -534,6 +534,11 @@ ZEROCONF = { "domain": "homekit_controller", }, ], + "_heos-audio._tcp.local.": [ + { + "domain": "heos", + }, + ], "_homeconnect._tcp.local.": [ { "domain": "home_connect", diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py index 835e4436398..e72c72c7334 100644 --- a/tests/components/heos/conftest.py +++ b/tests/components/heos/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Iterator +from ipaddress import ip_address from unittest.mock import Mock, patch from pyheos import ( @@ -39,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_UDN, SsdpServiceInfo, ) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -284,6 +286,36 @@ def discovery_data_fixture_bedroom() -> SsdpServiceInfo: ) +@pytest.fixture(name="zeroconf_discovery_data") +def zeroconf_discovery_data_fixture() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.1" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenon._heos-audio._tcp.local.", + properties={}, + ) + + +@pytest.fixture(name="zeroconf_discovery_data_bedroom") +def zeroconf_discovery_data_fixture_bedroom() -> ZeroconfServiceInfo: + """Return mock discovery data for testing.""" + host = "127.0.0.2" + return ZeroconfServiceInfo( + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], + port=10101, + hostname=host, + type="mock_type", + name="MyDenonBedroom._heos-audio._tcp.local.", + properties={}, + ) + + @pytest.fixture(name="quick_selects") def quick_selects_fixture() -> dict[int, str]: """Create a dict of quick selects for testing.""" diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 69d9aa3a38e..4749dc48b01 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -18,12 +18,14 @@ from homeassistant.config_entries import ( SOURCE_IGNORE, SOURCE_SSDP, SOURCE_USER, + SOURCE_ZEROCONF, ConfigEntryState, ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo from . import MockHeos @@ -244,6 +246,143 @@ async def test_discovery_updates( assert config_entry.data[CONF_HOST] == "127.0.0.2" +async def test_zeroconf_discovery( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + system: HeosSystem, +) -> None: + """Test discovery shows form to confirm, then creates entry.""" + # Single discovered, selects preferred host, shows confirm + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm_discovery" + assert controller.connect.call_count == 1 + assert controller.get_system_info.call_count == 1 + assert controller.disconnect.call_count == 1 + + # Subsequent discovered hosts abort. + subsequent_result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert subsequent_result["type"] is FlowResultType.ABORT + assert subsequent_result["reason"] == "already_in_progress" + + # Confirm set up + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == DOMAIN + assert result["title"] == "HEOS System" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + + +async def test_zeroconf_discovery_flow_aborts_already_setup( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + config_entry: MockConfigEntry, + controller: MockHeos, +) -> None: + """Test discovery flow aborts when entry already setup and hosts didn't change.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 0 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_aborts_same_system( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, + system: HeosSystem, +) -> None: + """Test discovery does not update when current host is part of discovered's system.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + controller.get_system_info.return_value = system + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + assert controller.get_system_info.call_count == 1 + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + +async def test_zeroconf_discovery_ignored_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, +) -> None: + """Test discovery aborts when ignored.""" + MockConfigEntry(domain=DOMAIN, unique_id=DOMAIN, source=SOURCE_IGNORE).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_zeroconf_discovery_fails_to_connect_aborts( + hass: HomeAssistant, + zeroconf_discovery_data: ZeroconfServiceInfo, + controller: MockHeos, +) -> None: + """Test discovery aborts when trying to connect to host.""" + controller.connect.side_effect = HeosError() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf_discovery_data + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + + +async def test_zeroconf_discovery_updates( + hass: HomeAssistant, + zeroconf_discovery_data_bedroom: ZeroconfServiceInfo, + controller: MockHeos, + config_entry: MockConfigEntry, +) -> None: + """Test discovery updates existing entry.""" + config_entry.add_to_hass(hass) + assert config_entry.data[CONF_HOST] == "127.0.0.1" + + host = HeosHost("Player", "Model", None, None, "127.0.0.2", NetworkType.WIRED, True) + controller.get_system_info.return_value = HeosSystem(None, host, [host]) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf_discovery_data_bedroom, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert config_entry.data[CONF_HOST] == "127.0.0.2" + + async def test_reconfigure_validates_and_updates_config( hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos ) -> None: