diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 8a0b171d5e7..984a138610f 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -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.""" diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 5323ee23965..ddb036bc5a6 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -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": { diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py new file mode 100644 index 00000000000..601c8d30503 --- /dev/null +++ b/tests/components/nobo_hub/test_init.py @@ -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