From 212ac7ab3a8cb3a3ad04e2dde45243baa83ee8e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Thu, 9 Jul 2026 15:23:08 +0200 Subject: [PATCH] Track nobo_hub connectivity (#170726) --- homeassistant/components/nobo_hub/__init__.py | 16 +++ homeassistant/components/nobo_hub/climate.py | 13 ++- homeassistant/components/nobo_hub/entity.py | 19 +++- .../components/nobo_hub/quality_scale.yaml | 4 +- homeassistant/components/nobo_hub/select.py | 13 ++- homeassistant/components/nobo_hub/sensor.py | 13 ++- tests/components/nobo_hub/__init__.py | 10 ++ tests/components/nobo_hub/conftest.py | 8 ++ tests/components/nobo_hub/test_init.py | 102 +++++++++++++++++- 9 files changed, 178 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index 59f3ad1789b8..daf5611f0424 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -1,5 +1,7 @@ """The Nobø Ecohub integration.""" +import logging + from pynobo import nobo from homeassistant.config_entries import ConfigEntry @@ -25,6 +27,8 @@ from .const import ( NOBO_MANUFACTURER, ) +_LOGGER = logging.getLogger(__name__) + PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] type NoboHubConfigEntry = ConfigEntry[nobo] @@ -80,6 +84,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: NoboHubConfigEntry) -> b entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close) ) + + def _log_connection_state(_hub: nobo, connected: bool) -> None: + """Log hub connection-state transitions.""" + if connected: + _LOGGER.info("Reconnected to Nobø Ecohub %s", serial) + else: + _LOGGER.info("Lost connection to Nobø Ecohub %s", serial) + + hub.register_connection_callback(_log_connection_state) + entry.async_on_unload( + lambda: hub.deregister_connection_callback(_log_connection_state) + ) entry.runtime_data = hub device_registry = dr.async_get(hass) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 6cb8449a5828..2ddd05e1bbd3 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -161,15 +161,18 @@ class NoboZone(NoboBaseEntity, ClimateEntity): """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/nobo_hub/entity.py b/homeassistant/components/nobo_hub/entity.py index 22445ed7150e..7ad26edb40bf 100644 --- a/homeassistant/components/nobo_hub/entity.py +++ b/homeassistant/components/nobo_hub/entity.py @@ -17,16 +17,21 @@ class NoboBaseEntity(Entity): def __init__(self, hub: nobo) -> None: """Initialize the entity.""" self._nobo = hub + self._attr_available = hub.connected @override async def async_added_to_hass(self) -> None: - """Register callback with hub.""" + """Register callbacks with hub.""" await super().async_added_to_hass() self._nobo.register_callback(self._handle_hub_update) + self._nobo.register_connection_callback(self._handle_hub_connection) + # Resync in case the state changed between __init__ and callback registration. + self._attr_available = self._nobo.connected @override async def async_will_remove_from_hass(self) -> None: - """Deregister callback from hub.""" + """Deregister callbacks from hub.""" + self._nobo.deregister_connection_callback(self._handle_hub_connection) self._nobo.deregister_callback(self._handle_hub_update) await super().async_will_remove_from_hass() @@ -36,6 +41,16 @@ class NoboBaseEntity(Entity): self._read_state() self.async_write_ha_state() + @callback + def _handle_hub_connection(self, _hub: nobo, connected: bool) -> None: + """Handle a connection-state transition from the hub.""" + self._attr_available = connected + if connected: + # Refresh state values so the first state write after reconnect + # carries fresh data, not whatever was cached pre-disconnect. + self._read_state() + self.async_write_ha_state() + @callback def _read_state(self) -> None: """Copy the current hub state from the pynobo client onto the entity attributes. diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index bd6a427a0c28..72de24495175 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -34,9 +34,9 @@ rules: config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done - log-when-unavailable: todo + log-when-unavailable: done parallel-updates: done reauthentication-flow: status: exempt diff --git a/homeassistant/components/nobo_hub/select.py b/homeassistant/components/nobo_hub/select.py index a4850fc6c76d..9c8313ebdfc5 100644 --- a/homeassistant/components/nobo_hub/select.py +++ b/homeassistant/components/nobo_hub/select.py @@ -143,15 +143,18 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity): """Fetch new state data for this zone.""" self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the zone still exists.""" + return super().available and self._id in self._nobo.zones + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.zones: - # Zone removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. These are only local calls.""" + if not self.available: return - self._attr_available = True self._profiles = { profile["week_profile_id"]: profile["name"].replace("\xa0", " ") for profile in self._nobo.week_profiles.values() diff --git a/homeassistant/components/nobo_hub/sensor.py b/homeassistant/components/nobo_hub/sensor.py index c82718449035..8ebea0b63419 100644 --- a/homeassistant/components/nobo_hub/sensor.py +++ b/homeassistant/components/nobo_hub/sensor.py @@ -69,14 +69,17 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity): ) self._read_state() + @property + @override + def available(self) -> bool: + """Available when the hub is connected and the component still exists.""" + return super().available and self._id in self._nobo.components + @callback @override def _read_state(self) -> None: - """Copy the current hub state onto the entity attributes.""" - if self._id not in self._nobo.components: - # Component removed via the Nobø app; mark unavailable. - self._attr_available = False + """Read the current state from the hub. This is a local call.""" + if not self.available: return - self._attr_available = True value = self._nobo.get_current_component_temperature(self._id) self._attr_native_value = None if value is None else float(value) diff --git a/tests/components/nobo_hub/__init__.py b/tests/components/nobo_hub/__init__.py index 24f63401951b..d487b000d04d 100644 --- a/tests/components/nobo_hub/__init__.py +++ b/tests/components/nobo_hub/__init__.py @@ -10,3 +10,13 @@ async def fire_hub_update(hass: HomeAssistant, hub: MagicMock) -> None: for call in hub.register_callback.call_args_list: call.args[0](hub) await hass.async_block_till_done() + + +async def fire_hub_connection( + hass: HomeAssistant, hub: MagicMock, connected: bool +) -> None: + """Fire the hub's registered connection-state callbacks and wait for state to settle.""" + hub.connected = connected + for call in hub.register_connection_callback.call_args_list: + call.args[0](hub, connected) + await hass.async_block_till_done() diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 7df91c55130e..974e803740c3 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -54,6 +54,12 @@ def config_entry_options() -> dict[str, Any]: return {} +@pytest.fixture +def hub_connected() -> bool: + """Whether the mocked hub reports itself connected after setup.""" + return True + + @pytest.fixture def mock_config_entry( ip_address: str, @@ -75,6 +81,7 @@ def mock_config_entry( @pytest.fixture def mock_nobo_class( connect_exc: BaseException | None, + hub_connected: bool, ) -> Generator[MagicMock]: """Patch the integration's imported `nobo` class with a populated hub instance.""" with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_cls: @@ -82,6 +89,7 @@ def mock_nobo_class( if connect_exc is not None: hub.connect.side_effect = connect_exc + hub.connected = hub_connected hub.hub_serial = SERIAL hub.hub_info = { "name": "My Eco Hub", diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index 7c628c6ec5bb..880aa49dd74f 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -1,5 +1,6 @@ """Tests for the Nobø Ecohub integration setup.""" +import logging from unittest.mock import MagicMock from pynobo import nobo as pynobo_nobo @@ -11,15 +12,23 @@ from homeassistant.components.nobo_hub.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC +from homeassistant.const import CONF_IP_ADDRESS, CONF_MAC, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr +from . import fire_hub_connection from .conftest import SERIAL, STORED_IP from tests.common import MockConfigEntry NEW_IP = "192.168.1.55" +GLOBAL_ENTITY = "select.my_eco_hub_global_override" + + +@pytest.fixture +def platforms(request: pytest.FixtureRequest) -> list[Platform]: + """Default to select; override per-test via indirect parametrize.""" + return getattr(request, "param", [Platform.SELECT]) async def test_setup_uses_stored_ip( @@ -224,3 +233,94 @@ async def test_setup_registers_hub_device_with_mac( assert device.connections == { (dr.CONNECTION_NETWORK_MAC, "7c:83:06:01:11:92"), } + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_available_when_hub_connected(hass: HomeAssistant) -> None: + """Entities are available when the hub reports connected.""" + state = hass.states.get(GLOBAL_ENTITY) + assert state is not None + assert state.state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_entity_unavailable_on_disconnect_and_recovers( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Entities become unavailable on disconnect and recover on reconnect.""" + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(GLOBAL_ENTITY).state == STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(GLOBAL_ENTITY).state != STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_log_on_disconnect_and_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Disconnects and reconnects both log at info level.""" + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, False) + assert any( + record.levelno == logging.INFO + and "Lost connection to Nobø Ecohub" in record.message + for record in caplog.records + ) + + caplog.clear() + await fire_hub_connection(hass, mock_nobo_hub, True) + assert any( + record.levelno == logging.INFO + and "Reconnected to Nobø Ecohub" in record.message + for record in caplog.records + ) + + +@pytest.mark.usefixtures("init_integration") +async def test_connection_callbacks_deregistered_on_unload( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nobo_hub: MagicMock, +) -> None: + """Every registered connection callback is deregistered on entry unload.""" + registered = mock_nobo_hub.register_connection_callback.call_count + assert registered > 0 + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_nobo_hub.deregister_connection_callback.call_count == registered + + +@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True) +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_during_disconnect_stays_unavailable_on_reconnect( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """A zone removed via the Nobø app while disconnected stays unavailable on reconnect. + + The connection callback fires before the data callback (pynobo Option C). + Without the `available` property's existence check, the connection callback's + `_attr_available = True` would briefly flip the entity to available before the + data callback's _read_state could re-mark it unavailable. + """ + entity = "climate.living_room_living_room" + assert hass.states.get(entity).state != STATE_UNAVAILABLE + + await fire_hub_connection(hass, mock_nobo_hub, False) + assert hass.states.get(entity).state == STATE_UNAVAILABLE + + # Simulate the zone being removed via the Nobø app while disconnected: + # by the time the hub reconnects and _get_initial_data runs, hub.zones + # no longer contains the zone. + mock_nobo_hub.zones = {} + + await fire_hub_connection(hass, mock_nobo_hub, True) + assert hass.states.get(entity).state == STATE_UNAVAILABLE