mirror of
https://github.com/home-assistant/core.git
synced 2025-07-28 07:37:34 +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.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."""
|
||||||
|
@ -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."]
|
||||||
}
|
}
|
||||||
|
5
homeassistant/generated/zeroconf.py
generated
5
homeassistant/generated/zeroconf.py
generated
@ -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",
|
||||||
|
@ -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."""
|
||||||
|
@ -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:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user