mirror of
https://github.com/home-assistant/core.git
synced 2026-05-16 05:21:35 +01:00
Improve nobo_hub config entry setup (#168550)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Norbert Rittel <norbert@rittel.de>
This commit is contained in:
committed by
GitHub
parent
528f7625f4
commit
b005fb236f
@@ -7,9 +7,10 @@ from pynobo import nobo
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL
|
||||
from .const import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]
|
||||
|
||||
@@ -20,16 +21,51 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b
|
||||
"""Set up Nobø Ecohub from a config entry."""
|
||||
|
||||
serial = entry.data[CONF_SERIAL]
|
||||
discover = entry.data[CONF_AUTO_DISCOVERED]
|
||||
ip_address = None if discover else entry.data[CONF_IP_ADDRESS]
|
||||
hub = nobo(
|
||||
serial=serial,
|
||||
ip=ip_address,
|
||||
discover=discover,
|
||||
synchronous=False,
|
||||
timezone=dt_util.get_default_time_zone(),
|
||||
)
|
||||
await hub.connect()
|
||||
stored_ip = entry.data[CONF_IP_ADDRESS]
|
||||
auto_discovered = entry.data[CONF_AUTO_DISCOVERED]
|
||||
|
||||
async def _connect(ip: str) -> nobo:
|
||||
hub = nobo(
|
||||
serial=serial,
|
||||
ip=ip,
|
||||
discover=False,
|
||||
synchronous=False,
|
||||
timezone=dt_util.get_default_time_zone(),
|
||||
)
|
||||
await hub.connect()
|
||||
return hub
|
||||
|
||||
try:
|
||||
hub = await _connect(stored_ip)
|
||||
except OSError as err:
|
||||
if not auto_discovered:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_manual",
|
||||
translation_placeholders={"serial": serial, "ip": stored_ip},
|
||||
) from err
|
||||
# Stored IP may be stale for an auto-discovered entry - try UDP
|
||||
# rediscovery to pick up a new DHCP lease.
|
||||
discovered = await nobo.async_discover_hubs(serial=serial)
|
||||
if not discovered:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="hub_not_found",
|
||||
translation_placeholders={"serial": serial},
|
||||
) from err
|
||||
new_ip, _ = next(iter(discovered))
|
||||
try:
|
||||
hub = await _connect(new_ip)
|
||||
except OSError as rediscover_err:
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect_rediscovered",
|
||||
translation_placeholders={"ip": new_ip},
|
||||
) from rediscover_err
|
||||
if new_ip != stored_ip:
|
||||
hass.config_entries.async_update_entry(
|
||||
entry, data={**entry.data, CONF_IP_ADDRESS: new_ip}
|
||||
)
|
||||
|
||||
async def _async_close(event):
|
||||
"""Close the Nobø Ecohub socket connection when HA stops."""
|
||||
|
||||
@@ -47,6 +47,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"cannot_connect_manual": {
|
||||
"message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}. If the hub has moved to a new IP address, remove and re-add the integration."
|
||||
},
|
||||
"cannot_connect_rediscovered": {
|
||||
"message": "Unable to connect to Nobø Ecohub at rediscovered IP {ip}; will retry."
|
||||
},
|
||||
"hub_not_found": {
|
||||
"message": "Nobø Ecohub with serial {serial} not found on the network. The hub may be offline or on a different subnet; will retry."
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Tests for the Nobø Ecohub integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.nobo_hub import async_setup_entry
|
||||
from homeassistant.components.nobo_hub.const import (
|
||||
CONF_AUTO_DISCOVERED,
|
||||
CONF_SERIAL,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_IP_ADDRESS
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
SERIAL = "102000013098"
|
||||
STORED_IP = "192.168.1.122"
|
||||
NEW_IP = "192.168.1.55"
|
||||
|
||||
|
||||
def _make_entry(
|
||||
hass: HomeAssistant,
|
||||
*,
|
||||
auto_discovered: bool,
|
||||
ip_address: str = STORED_IP,
|
||||
) -> MockConfigEntry:
|
||||
"""Create a mock config entry for Nobø Ecohub."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="My Eco Hub",
|
||||
unique_id=SERIAL,
|
||||
data={
|
||||
CONF_SERIAL: SERIAL,
|
||||
CONF_IP_ADDRESS: ip_address,
|
||||
CONF_AUTO_DISCOVERED: auto_discovered,
|
||||
},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
return entry
|
||||
|
||||
|
||||
def _make_hub_mock(connect_exc: BaseException | None = None) -> MagicMock:
|
||||
"""Create a mock pynobo.nobo instance."""
|
||||
hub = MagicMock()
|
||||
hub.connect = AsyncMock(side_effect=connect_exc)
|
||||
hub.start = AsyncMock()
|
||||
hub.stop = AsyncMock()
|
||||
hub.register_callback = MagicMock()
|
||||
hub.deregister_callback = MagicMock()
|
||||
hub.hub_serial = SERIAL
|
||||
hub.hub_info = {
|
||||
"name": "My Eco Hub",
|
||||
"serial": SERIAL,
|
||||
"software_version": "115",
|
||||
"hardware_version": "hw",
|
||||
}
|
||||
hub.zones = {}
|
||||
hub.components = {}
|
||||
hub.overrides = {}
|
||||
hub.week_profiles = {}
|
||||
return hub
|
||||
|
||||
|
||||
async def test_setup_manual_entry_uses_stored_ip(hass: HomeAssistant) -> None:
|
||||
"""Manual entry connects using the stored IP without rediscovery."""
|
||||
entry = _make_entry(hass, auto_discovered=False)
|
||||
hub = _make_hub_mock()
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.return_value = hub
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value=set())
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert mock_cls.call_args.kwargs["ip"] == STORED_IP
|
||||
assert mock_cls.call_args.kwargs["discover"] is False
|
||||
mock_cls.async_discover_hubs.assert_not_called()
|
||||
|
||||
|
||||
async def test_setup_autodiscovered_entry_uses_stored_ip(hass: HomeAssistant) -> None:
|
||||
"""Auto-discovered entry with a working stored IP does not rediscover."""
|
||||
entry = _make_entry(hass, auto_discovered=True)
|
||||
hub = _make_hub_mock()
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.return_value = hub
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value=set())
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
mock_cls.async_discover_hubs.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"connect_exc",
|
||||
[OSError("Unreachable"), TimeoutError("Handshake timed out")],
|
||||
)
|
||||
async def test_setup_manual_entry_connection_fails(
|
||||
hass: HomeAssistant,
|
||||
connect_exc: BaseException,
|
||||
) -> None:
|
||||
"""Manual entry raises ConfigEntryNotReady on socket errors or timeouts."""
|
||||
entry = _make_entry(hass, auto_discovered=False)
|
||||
hub = _make_hub_mock(connect_exc=connect_exc)
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.return_value = hub
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value=set())
|
||||
with pytest.raises(ConfigEntryNotReady) as exc_info:
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
assert exc_info.value.translation_key == "cannot_connect_manual"
|
||||
assert exc_info.value.translation_placeholders == {
|
||||
"serial": SERIAL,
|
||||
"ip": STORED_IP,
|
||||
}
|
||||
mock_cls.async_discover_hubs.assert_not_called()
|
||||
|
||||
|
||||
async def test_setup_autodiscovered_rediscovery_updates_ip(hass: HomeAssistant) -> None:
|
||||
"""Auto-discovered entry recovers via rediscovery and persists the new IP."""
|
||||
entry = _make_entry(hass, auto_discovered=True)
|
||||
hub_fail = _make_hub_mock(connect_exc=OSError("Unreachable"))
|
||||
hub_ok = _make_hub_mock()
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.side_effect = [hub_fail, hub_ok]
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value={(NEW_IP, SERIAL)})
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.data[CONF_IP_ADDRESS] == NEW_IP
|
||||
assert mock_cls.call_count == 2
|
||||
assert mock_cls.call_args_list[0].kwargs["ip"] == STORED_IP
|
||||
assert mock_cls.call_args_list[1].kwargs["ip"] == NEW_IP
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"discovered_hubs",
|
||||
"rediscovered_connect_fails",
|
||||
"expected_key",
|
||||
"expected_placeholders",
|
||||
),
|
||||
[
|
||||
(set(), False, "hub_not_found", {"serial": SERIAL}),
|
||||
({(NEW_IP, SERIAL)}, True, "cannot_connect_rediscovered", {"ip": NEW_IP}),
|
||||
],
|
||||
ids=["rediscovery_empty", "rediscovered_ip_fails"],
|
||||
)
|
||||
async def test_setup_autodiscovered_rediscovery_failure(
|
||||
hass: HomeAssistant,
|
||||
discovered_hubs: set[tuple[str, str]],
|
||||
rediscovered_connect_fails: bool,
|
||||
expected_key: str,
|
||||
expected_placeholders: dict[str, str],
|
||||
) -> None:
|
||||
"""Auto-discovered entry raises the right error when rediscovery can't recover."""
|
||||
entry = _make_entry(hass, auto_discovered=True)
|
||||
hub_first = _make_hub_mock(connect_exc=OSError("Unreachable"))
|
||||
hub_second = _make_hub_mock(
|
||||
connect_exc=OSError("Unreachable") if rediscovered_connect_fails else None
|
||||
)
|
||||
with patch("homeassistant.components.nobo_hub.nobo") as mock_cls:
|
||||
mock_cls.side_effect = [hub_first, hub_second]
|
||||
mock_cls.async_discover_hubs = AsyncMock(return_value=discovered_hubs)
|
||||
with pytest.raises(ConfigEntryNotReady) as exc_info:
|
||||
await async_setup_entry(hass, entry)
|
||||
|
||||
assert exc_info.value.translation_key == expected_key
|
||||
assert exc_info.value.translation_placeholders == expected_placeholders
|
||||
Reference in New Issue
Block a user