Files
core/tests/components/nobo_hub/test_init.py
T

531 lines
19 KiB
Python

"""Tests for the Nobø Ecohub integration setup."""
import logging
from unittest.mock import MagicMock
from pynobo import PynoboConnectionError, nobo as pynobo_nobo
import pytest
from homeassistant.components.nobo_hub.const import (
CONF_OVERRIDE_TYPE,
CONF_SERIAL,
DOMAIN,
)
from homeassistant.config_entries import ConfigEntryState
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, entity_registry as er
from . import (
device_identifiers,
dispatch_hub_update,
entity_unique_ids,
fire_hub_connection,
fire_hub_update,
)
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(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nobo_class: MagicMock,
) -> None:
"""Setup connects using the stored IP without invoking rediscovery."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_nobo_class.call_args.kwargs["ip"] == STORED_IP
assert mock_nobo_class.call_args.kwargs["discover"] is False
mock_nobo_class.async_discover_hubs.assert_not_called()
async def test_setup_rediscovery_updates_ip(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nobo_class: MagicMock,
) -> None:
"""A failed direct connect falls back to rediscovery and persists the new IP."""
mock_config_entry.add_to_hass(hass)
failing_hub = MagicMock(spec=pynobo_nobo)
failing_hub.connect.side_effect = PynoboConnectionError("Unreachable")
mock_nobo_class.side_effect = [failing_hub, mock_nobo_class.return_value]
mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)}
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_config_entry.data[CONF_IP_ADDRESS] == NEW_IP
assert mock_nobo_class.call_count == 2
assert mock_nobo_class.call_args_list[0].kwargs["ip"] == STORED_IP
assert mock_nobo_class.call_args_list[1].kwargs["ip"] == NEW_IP
async def test_setup_retries_when_rediscovery_finds_nothing(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nobo_class: MagicMock,
) -> None:
"""Setup retries when stored IP fails and rediscovery is empty."""
mock_config_entry.add_to_hass(hass)
failing_hub = MagicMock(spec=pynobo_nobo)
failing_hub.connect.side_effect = PynoboConnectionError("Unreachable")
mock_nobo_class.side_effect = [failing_hub]
mock_nobo_class.async_discover_hubs.return_value = set()
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_config_entry.error_reason_translation_key == "cannot_connect"
assert mock_config_entry.error_reason_translation_placeholders == {
"serial": SERIAL,
"ip": STORED_IP,
}
async def test_setup_retries_when_rediscovered_ip_also_fails(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nobo_class: MagicMock,
) -> None:
"""Setup retries when both stored and rediscovered IPs fail."""
mock_config_entry.add_to_hass(hass)
first_failing_hub = MagicMock(spec=pynobo_nobo)
first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable")
second_failing_hub = MagicMock(spec=pynobo_nobo)
second_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable")
mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub]
mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)}
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_config_entry.error_reason_translation_key == "cannot_connect"
assert mock_config_entry.error_reason_translation_placeholders == {
"serial": SERIAL,
"ip": NEW_IP,
}
async def test_setup_does_not_catch_plain_os_error_on_rediscovered_ip(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_nobo_class: MagicMock,
) -> None:
"""A plain OSError from the rediscovered IP is not caught by the fallback."""
mock_config_entry.add_to_hass(hass)
first_failing_hub = MagicMock(spec=pynobo_nobo)
first_failing_hub.connect.side_effect = PynoboConnectionError("Unreachable")
second_failing_hub = MagicMock(spec=pynobo_nobo)
second_failing_hub.connect.side_effect = OSError("boom")
mock_nobo_class.side_effect = [first_failing_hub, second_failing_hub]
mock_nobo_class.async_discover_hubs.return_value = {(NEW_IP, SERIAL)}
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
assert mock_nobo_class.call_count == 2
@pytest.mark.parametrize(
("stored_options", "expected_options"),
[
({CONF_OVERRIDE_TYPE: "Constant"}, {CONF_OVERRIDE_TYPE: "constant"}),
({CONF_OVERRIDE_TYPE: "Now"}, {CONF_OVERRIDE_TYPE: "now"}),
({CONF_OVERRIDE_TYPE: "constant"}, {CONF_OVERRIDE_TYPE: "constant"}),
({}, {}),
],
ids=["Constant", "Now", "already_lowercase", "no_options"],
)
async def test_migrate_options(
hass: HomeAssistant,
mock_nobo_class: MagicMock,
stored_options: dict[str, str],
expected_options: dict[str, str],
) -> None:
"""Migrating from minor_version 1 lowercases override_type and bumps version."""
entry = MockConfigEntry(
domain=DOMAIN,
title="My Eco Hub",
unique_id=SERIAL,
data={
CONF_SERIAL: SERIAL,
CONF_IP_ADDRESS: STORED_IP,
},
options=stored_options,
version=1,
minor_version=1,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.minor_version == 3
assert entry.options == expected_options
async def test_migrate_data_drops_auto_discovered(
hass: HomeAssistant,
mock_nobo_class: MagicMock,
) -> None:
"""The auto_discovered key is stripped from entry.data on migration."""
entry = MockConfigEntry(
domain=DOMAIN,
title="My Eco Hub",
unique_id=SERIAL,
data={
CONF_SERIAL: SERIAL,
CONF_IP_ADDRESS: STORED_IP,
"auto_discovered": True,
},
version=1,
minor_version=2,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.minor_version == 3
assert entry.data == {
CONF_SERIAL: SERIAL,
CONF_IP_ADDRESS: STORED_IP,
}
assert entry.options == {}
async def test_setup_registers_hub_device(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_nobo_class: MagicMock,
) -> None:
"""The hub device is registered with the expected metadata."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)})
assert device is not None
assert device.config_entries == {mock_config_entry.entry_id}
assert device.name == "My Eco Hub"
assert device.manufacturer == "Glen Dimplex Nordic AS"
assert device.model == "Nobø Ecohub"
assert device.serial_number == SERIAL
assert device.sw_version == "115"
assert device.hw_version == "hw"
assert device.connections == set()
async def test_setup_registers_hub_device_with_mac(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_nobo_class: MagicMock,
) -> None:
"""An entry with a stored MAC surfaces it via DeviceInfo.connections."""
entry = MockConfigEntry(
domain=DOMAIN,
title="My Eco Hub",
unique_id=SERIAL,
data={
CONF_SERIAL: SERIAL,
CONF_IP_ADDRESS: STORED_IP,
CONF_MAC: "7C8306011192",
},
version=1,
minor_version=3,
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)})
assert device is not None
assert device.connections == {
(dr.CONNECTION_NETWORK_MAC, "7c:83:06:01:11:92"),
}
@pytest.mark.parametrize(
"platforms", [[Platform.CLIMATE, Platform.SENSOR]], indirect=True
)
@pytest.mark.usefixtures("init_integration")
async def test_zone_and_component_devices_link_to_hub(
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Zone and component devices reference the hub device via via_device_id."""
entry_id = mock_config_entry.entry_id
hub_device = device_registry.async_get_device_by_identifier(
(DOMAIN, SERIAL), entry_id
)
assert hub_device is not None
zone_device = device_registry.async_get_device_by_identifier(
(DOMAIN, f"{SERIAL}:1"), entry_id
)
assert zone_device is not None
assert zone_device.via_device_id == hub_device.id
component_device = device_registry.async_get_device_by_identifier(
(DOMAIN, "200000059091"), entry_id
)
assert component_device is not None
assert component_device.via_device_id == hub_device.id
@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
@pytest.mark.usefixtures("init_integration")
async def test_removed_zone_removes_device(
hass: HomeAssistant,
mock_nobo_hub: MagicMock,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Removing a zone on the hub removes its device but keeps the hub device."""
entry_id = mock_config_entry.entry_id
assert (DOMAIN, f"{SERIAL}:1") in device_identifiers(device_registry, entry_id)
del mock_nobo_hub.zones["1"]
await fire_hub_update(hass, mock_nobo_hub)
identifiers = device_identifiers(device_registry, entry_id)
assert (DOMAIN, f"{SERIAL}:1") not in identifiers
assert (DOMAIN, SERIAL) in identifiers
@pytest.mark.parametrize("platforms", [[Platform.SENSOR]], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_removed_component_removes_device(
hass: HomeAssistant,
mock_nobo_hub: MagicMock,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Removing a temperature-sensor component on the hub removes its device."""
entry_id = mock_config_entry.entry_id
assert (DOMAIN, "200000059091") in device_identifiers(device_registry, entry_id)
del mock_nobo_hub.components["200000059091"]
await fire_hub_update(hass, mock_nobo_hub)
assert (DOMAIN, "200000059091") not in device_identifiers(device_registry, entry_id)
@pytest.mark.usefixtures("init_integration")
async def test_disconnected_hub_does_not_remove_devices(
hass: HomeAssistant,
mock_nobo_hub: MagicMock,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Devices are retained when topology looks empty because the hub is disconnected."""
entry_id = mock_config_entry.entry_id
before = device_identifiers(device_registry, entry_id)
mock_nobo_hub.connected = False
mock_nobo_hub.zones.clear()
mock_nobo_hub.components.clear()
await fire_hub_update(hass, mock_nobo_hub)
assert device_identifiers(device_registry, entry_id) == before
@pytest.mark.parametrize(
"platforms",
[[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]],
indirect=True,
)
@pytest.mark.usefixtures("init_integration")
async def test_disconnect_does_not_readd_entities_on_reconnect(
hass: HomeAssistant,
mock_nobo_hub: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A stale empty topology while disconnected must not forget known ids.
Otherwise the reconcile would clear the known-id sets and re-add every
entity on reconnect, colliding with the still-registered unique ids.
"""
saved_zones = dict(mock_nobo_hub.zones)
saved_components = dict(mock_nobo_hub.components)
mock_nobo_hub.connected = False
mock_nobo_hub.zones.clear()
mock_nobo_hub.components.clear()
await fire_hub_update(hass, mock_nobo_hub)
mock_nobo_hub.connected = True
mock_nobo_hub.zones.update(saved_zones)
mock_nobo_hub.components.update(saved_components)
await fire_hub_update(hass, mock_nobo_hub)
assert "already exists" not in caplog.text
@pytest.mark.parametrize("platforms", [[Platform.CLIMATE]], indirect=True)
@pytest.mark.usefixtures("init_integration")
async def test_buffered_remove_then_readd_same_id(
hass: HomeAssistant,
mock_nobo_hub: MagicMock,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A buffered remove + same-id re-add (no event-loop yield between) re-registers cleanly.
pynobo can process a delete and an id-reusing add back-to-back before the
loop yields (buffered messages), so synchronous device removal must fully
deregister the old entity before the re-add, or the add collides with the
still-registered unique id.
"""
entry_id = mock_config_entry.entry_id
zone = {
"zone_id": "2",
"name": "Bedroom",
"week_profile_id": "0",
"temp_comfort_c": "22",
"temp_eco_c": "18",
}
mock_nobo_hub.zones["2"] = zone
await fire_hub_update(hass, mock_nobo_hub)
assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id)
# Remove then re-add the same id with no await (no event-loop yield) between.
del mock_nobo_hub.zones["2"]
dispatch_hub_update(mock_nobo_hub)
mock_nobo_hub.zones["2"] = zone
dispatch_hub_update(mock_nobo_hub)
await hass.async_block_till_done()
assert f"{SERIAL}:2" in entity_unique_ids(entity_registry, entry_id)
assert "already exists" not in caplog.text
@pytest.mark.usefixtures("mock_nobo_class")
async def test_stale_device_pruned_at_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""A device for a zone removed while Home Assistant was down is pruned at setup."""
mock_config_entry.add_to_hass(hass)
stale_device = (DOMAIN, f"{SERIAL}:99")
device_registry.async_get_or_create(
config_entry_id=mock_config_entry.entry_id,
identifiers={stale_device},
)
entry_id = mock_config_entry.entry_id
assert stale_device in device_identifiers(device_registry, entry_id)
assert await hass.config_entries.async_setup(entry_id)
await hass.async_block_till_done()
assert stale_device not in device_identifiers(device_registry, entry_id)