Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 51 additions & 41 deletions homeassistant/components/heos/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
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
Expand Down Expand Up @@ -142,51 +143,16 @@ async def async_step_ssdp(
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")

# 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
return await self._async_handle_discovered(hostname)

# 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
Expand Down Expand Up @@ -267,6 +233,50 @@ async def async_step_reauth_confirm(
),
)

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."""
Expand Down
3 changes: 2 additions & 1 deletion homeassistant/components/heos/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@
{
"st": "urn:schemas-denon-com:device:ACT-Denon:1"
}
]
],
"zeroconf": ["_heos-audio._tcp.local."]
}
5 changes: 0 additions & 5 deletions homeassistant/components/teslemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
energysite.info_coordinator.async_config_entry_first_refresh()
for energysite in energysites
),
*(
energysite.history_coordinator.async_config_entry_first_refresh()
for energysite in energysites
if energysite.history_coordinator
),
)

# Add energy device models
Expand Down
1 change: 1 addition & 0 deletions homeassistant/components/teslemetry/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ def __init__(
update_interval=ENERGY_HISTORY_INTERVAL,
)
self.api = api
self.data = {}

async def _async_update_data(self) -> dict[str, Any]:
"""Update energy site data using Teslemetry API."""
Expand Down
10 changes: 7 additions & 3 deletions homeassistant/components/zwave_js/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ class ZWaveDiscoverySchema:
)

# For device class mapping see:
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json
# https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/
DISCOVERY_SCHEMAS = [
# ====== START OF DEVICE SPECIFIC MAPPING SCHEMAS =======
# Honeywell 39358 In-Wall Fan Control using switch multilevel CC
Expand Down Expand Up @@ -291,12 +291,16 @@ class ZWaveDiscoverySchema:
FanValueMapping(speeds=[(1, 33), (34, 67), (68, 99)]),
),
),
# GE/Jasco - In-Wall Smart Fan Control - 14287 / 55258 / ZW4002
# GE/Jasco - In-Wall Smart Fan Controls
ZWaveDiscoverySchema(
platform=Platform.FAN,
hint="has_fan_value_mapping",
manufacturer_id={0x0063},
product_id={0x3131, 0x3337},
product_id={
0x3131,
0x3337, # 14287 / 55258 / ZW4002
0x3533, # 58446 / ZWA4013
},
product_type={0x4944},
primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA,
data_template=FixedFanValueMappingDataTemplate(
Expand Down
5 changes: 5 additions & 0 deletions homeassistant/generated/zeroconf.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions tests/components/heos/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -39,6 +40,7 @@
ATTR_UPNP_UDN,
SsdpServiceInfo,
)
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo

from . import MockHeos

Expand Down Expand Up @@ -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."""
Expand Down
139 changes: 139 additions & 0 deletions tests/components/heos/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@
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

Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading