Discover Heos players using Zeroconf (#144763)

This commit is contained in:
Robert Meijers 2025-07-13 16:56:31 +02:00 committed by GitHub
parent f7d132b043
commit 023dd9d523
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 229 additions and 42 deletions

View File

@ -24,6 +24,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import selector from homeassistant.helpers import selector
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN, ENTRY_TITLE from .const import DOMAIN, ENTRY_TITLE
from .coordinator import HeosConfigEntry from .coordinator import HeosConfigEntry
@ -142,51 +143,16 @@ class HeosFlowHandler(ConfigFlow, domain=DOMAIN):
if TYPE_CHECKING: if TYPE_CHECKING:
assert discovery_info.ssdp_location assert discovery_info.ssdp_location
entry: HeosConfigEntry | None = await self.async_set_unique_id(DOMAIN)
hostname = urlparse(discovery_info.ssdp_location).hostname hostname = urlparse(discovery_info.ssdp_location).hostname
assert hostname is not None assert hostname is not None
# Abort early when discovery is ignored or host is part of the current system return await self._async_handle_discovered(hostname)
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 async def async_step_zeroconf(
heos = Heos(HeosOptions(hostname, events=False, heart_beat=False)) self, discovery_info: ZeroconfServiceInfo
try: ) -> ConfigFlowResult:
await heos.connect() """Handle zeroconf discovery."""
system_info = await heos.get_system_info() return await self._async_handle_discovered(discovery_info.host)
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_confirm_discovery( async def async_step_confirm_discovery(
self, user_input: dict[str, Any] | None = None 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): class HeosOptionsFlowHandler(OptionsFlow):
"""Define HEOS options flow.""" """Define HEOS options flow."""

View File

@ -13,5 +13,6 @@
{ {
"st": "urn:schemas-denon-com:device:ACT-Denon:1" "st": "urn:schemas-denon-com:device:ACT-Denon:1"
} }
] ],
"zeroconf": ["_heos-audio._tcp.local."]
} }

View File

@ -534,6 +534,11 @@ ZEROCONF = {
"domain": "homekit_controller", "domain": "homekit_controller",
}, },
], ],
"_heos-audio._tcp.local.": [
{
"domain": "heos",
},
],
"_homeconnect._tcp.local.": [ "_homeconnect._tcp.local.": [
{ {
"domain": "home_connect", "domain": "home_connect",

View File

@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable, Iterator from collections.abc import Callable, Iterator
from ipaddress import ip_address
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from pyheos import ( from pyheos import (
@ -39,6 +40,7 @@ from homeassistant.helpers.service_info.ssdp import (
ATTR_UPNP_UDN, ATTR_UPNP_UDN,
SsdpServiceInfo, SsdpServiceInfo,
) )
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import MockHeos 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") @pytest.fixture(name="quick_selects")
def quick_selects_fixture() -> dict[int, str]: def quick_selects_fixture() -> dict[int, str]:
"""Create a dict of quick selects for testing.""" """Create a dict of quick selects for testing."""

View File

@ -18,12 +18,14 @@ from homeassistant.config_entries import (
SOURCE_IGNORE, SOURCE_IGNORE,
SOURCE_SSDP, SOURCE_SSDP,
SOURCE_USER, SOURCE_USER,
SOURCE_ZEROCONF,
ConfigEntryState, ConfigEntryState,
) )
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo from homeassistant.helpers.service_info.ssdp import SsdpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from . import MockHeos from . import MockHeos
@ -244,6 +246,143 @@ async def test_discovery_updates(
assert config_entry.data[CONF_HOST] == "127.0.0.2" 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( async def test_reconfigure_validates_and_updates_config(
hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos
) -> None: ) -> None: