mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 15:47:12 +00:00
Discover Heos players using Zeroconf (#144763)
This commit is contained in:
parent
f7d132b043
commit
023dd9d523
@ -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."""
|
||||
|
@ -13,5 +13,6 @@
|
||||
{
|
||||
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
|
||||
}
|
||||
]
|
||||
],
|
||||
"zeroconf": ["_heos-audio._tcp.local."]
|
||||
}
|
||||
|
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@ -534,6 +534,11 @@ ZEROCONF = {
|
||||
"domain": "homekit_controller",
|
||||
},
|
||||
],
|
||||
"_heos-audio._tcp.local.": [
|
||||
{
|
||||
"domain": "heos",
|
||||
},
|
||||
],
|
||||
"_homeconnect._tcp.local.": [
|
||||
{
|
||||
"domain": "home_connect",
|
||||
|
@ -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."""
|
||||
|
@ -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:
|
||||
|
Loading…
x
Reference in New Issue
Block a user