1
0
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:
Øyvind Matheson Wergeland
2026-04-20 09:08:49 +02:00
committed by GitHub
parent 528f7625f4
commit b005fb236f
3 changed files with 232 additions and 11 deletions
+47 -11
View File
@@ -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": {
+174
View File
@@ -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