From 2fe8e772733a1290e7757191fd6df7981fe20467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Sun, 30 Aug 2026 09:16:28 +0200 Subject: [PATCH] Add per-zone global override switch to nobo_hub (#177772) --- homeassistant/components/nobo_hub/__init__.py | 2 +- homeassistant/components/nobo_hub/const.py | 1 + homeassistant/components/nobo_hub/icons.json | 8 + .../components/nobo_hub/quality_scale.yaml | 6 +- .../components/nobo_hub/strings.json | 8 + homeassistant/components/nobo_hub/switch.py | 111 ++++++++++++ tests/components/nobo_hub/conftest.py | 1 + .../nobo_hub/snapshots/test_diagnostics.ambr | 1 + .../nobo_hub/snapshots/test_switch.ambr | 51 ++++++ tests/components/nobo_hub/test_init.py | 2 +- tests/components/nobo_hub/test_switch.py | 170 ++++++++++++++++++ 11 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/nobo_hub/switch.py create mode 100644 tests/components/nobo_hub/snapshots/test_switch.ambr create mode 100644 tests/components/nobo_hub/test_switch.py diff --git a/homeassistant/components/nobo_hub/__init__.py b/homeassistant/components/nobo_hub/__init__.py index b053247bc50a..732ed27a9b91 100644 --- a/homeassistant/components/nobo_hub/__init__.py +++ b/homeassistant/components/nobo_hub/__init__.py @@ -29,7 +29,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR] +PLATFORMS = [Platform.CLIMATE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] type NoboHubConfigEntry = ConfigEntry[nobo] diff --git a/homeassistant/components/nobo_hub/const.py b/homeassistant/components/nobo_hub/const.py index f69dce354bf0..8c9ebe90c435 100644 --- a/homeassistant/components/nobo_hub/const.py +++ b/homeassistant/components/nobo_hub/const.py @@ -18,6 +18,7 @@ NOBO_MANUFACTURER = "Glen Dimplex Nordic AS" ATTR_HARDWARE_VERSION: Final = "hardware_version" ATTR_SOFTWARE_VERSION: Final = "software_version" ATTR_SERIAL: Final = "serial" +ATTR_OVERRIDE_ALLOWED: Final = "override_allowed" ATTR_TEMP_COMFORT_C: Final = "temp_comfort_c" ATTR_TEMP_ECO_C: Final = "temp_eco_c" ATTR_ZONE_ID: Final = "zone_id" diff --git a/homeassistant/components/nobo_hub/icons.json b/homeassistant/components/nobo_hub/icons.json index 74c20b18fe5b..407c97f5f674 100644 --- a/homeassistant/components/nobo_hub/icons.json +++ b/homeassistant/components/nobo_hub/icons.json @@ -12,6 +12,14 @@ "week_profile": { "default": "mdi:calendar-clock" } + }, + "switch": { + "disable_global_override": { + "default": "mdi:calendar-lock-open-outline", + "state": { + "on": "mdi:calendar-lock-outline" + } + } } } } diff --git a/homeassistant/components/nobo_hub/quality_scale.yaml b/homeassistant/components/nobo_hub/quality_scale.yaml index a28f19e5524c..53f88642f90d 100644 --- a/homeassistant/components/nobo_hub/quality_scale.yaml +++ b/homeassistant/components/nobo_hub/quality_scale.yaml @@ -59,11 +59,7 @@ rules: docs-troubleshooting: done docs-use-cases: done dynamic-devices: done - entity-category: - status: exempt - comment: > - All entities are primary controls or measurements; none are configuration - or diagnostic entities that need a non-default entity category. + entity-category: done entity-device-class: done entity-disabled-by-default: status: exempt diff --git a/homeassistant/components/nobo_hub/strings.json b/homeassistant/components/nobo_hub/strings.json index 4725b8cb05d8..08a8b162e03e 100644 --- a/homeassistant/components/nobo_hub/strings.json +++ b/homeassistant/components/nobo_hub/strings.json @@ -68,12 +68,20 @@ "week_profile": { "name": "Week profile" } + }, + "switch": { + "disable_global_override": { + "name": "Disable global overrides" + } } }, "exceptions": { "cannot_connect": { "message": "Unable to connect to Nobø Ecohub with serial {serial} at {ip}; will retry. If the hub is on a different network from Home Assistant and has changed IP address, reconfigure the integration with the new IP address." }, + "set_disable_global_override_failed": { + "message": "Failed to change whether global overrides are disabled for the zone." + }, "set_global_override_failed": { "message": "Failed to set global override." }, diff --git a/homeassistant/components/nobo_hub/switch.py b/homeassistant/components/nobo_hub/switch.py new file mode 100644 index 000000000000..e3025d8a7111 --- /dev/null +++ b/homeassistant/components/nobo_hub/switch.py @@ -0,0 +1,111 @@ +"""Python Control of Nobø Hub - Nobø Energy Control.""" + +from typing import Any, override + +from pynobo import PynoboError, nobo + +from homeassistant.components.switch import SwitchEntity +from homeassistant.const import ATTR_NAME, EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NoboHubConfigEntry +from .const import ATTR_OVERRIDE_ALLOWED, ATTR_SERIAL, DOMAIN +from .entity import NoboBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: NoboHubConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the disable-global-override switches for the Nobø Ecohub.""" + hub = config_entry.runtime_data + + known_zones: set[str] = set() + + @callback + def _add_switches(_hub: nobo) -> None: + """Add disable-global-override switches for zones added to the hub.""" + if hub.connected: + # Forget zones no longer on the hub so a removed-then-re-added zone + # (the hub reuses zone ids) is detected as new again. Skip while + # disconnected: a stale/empty snapshot would drop live zones and + # cause duplicate re-adds on reconnect. + known_zones.intersection_update(hub.zones) + new_zones = [zone_id for zone_id in hub.zones if zone_id not in known_zones] + known_zones.update(new_zones) + async_add_entities( + NoboDisableGlobalOverrideSwitch(zone_id, hub) for zone_id in new_zones + ) + + _add_switches(hub) + hub.register_callback(_add_switches) + config_entry.async_on_unload(lambda: hub.deregister_callback(_add_switches)) + + +class NoboDisableGlobalOverrideSwitch(NoboBaseEntity, SwitchEntity): + """Controls whether a zone is excluded from the hub's global override. + + When on the zone keeps its week profile regardless of any global override + set on the hub; when off the zone reacts to global overrides. Mirrors the + "Disable global overrides" setting in the Nobø app. + """ + + _attr_translation_key = "disable_global_override" + _attr_entity_category = EntityCategory.CONFIG + + def __init__(self, zone_id: str, hub: nobo) -> None: + """Initialize the disable-global-override switch.""" + super().__init__(hub) + self._id = zone_id + self._attr_unique_id = f"{hub.hub_serial}:{zone_id}:disable_global_override" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, + name=hub.zones[zone_id][ATTR_NAME], + via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]), + suggested_area=hub.zones[zone_id][ATTR_NAME], + ) + self._read_state() + + @override + async def async_turn_on(self, **kwargs: Any) -> None: + """Exclude this zone from global overrides.""" + await self._set_override_allowed(nobo.API.OVERRIDE_NOT_ALLOWED) + + @override + async def async_turn_off(self, **kwargs: Any) -> None: + """Allow global overrides to affect this zone.""" + await self._set_override_allowed(nobo.API.OVERRIDE_ALLOWED) + + async def _set_override_allowed(self, override_allowed: str) -> None: + try: + await self._nobo.async_update_zone( + self._id, override_allowed=override_allowed + ) + except PynoboError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="set_disable_global_override_failed", + ) from err + + @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: + """Read the current state from the hub. This is a local call.""" + if not self.available: + return + self._attr_is_on = ( + self._nobo.zones[self._id][ATTR_OVERRIDE_ALLOWED] + == nobo.API.OVERRIDE_NOT_ALLOWED + ) diff --git a/tests/components/nobo_hub/conftest.py b/tests/components/nobo_hub/conftest.py index 0cab97fed99c..632a0af0517d 100644 --- a/tests/components/nobo_hub/conftest.py +++ b/tests/components/nobo_hub/conftest.py @@ -104,6 +104,7 @@ def mock_nobo_class( "week_profile_id": "0", "temp_comfort_c": "21", "temp_eco_c": "17", + "override_allowed": "1", }, } model = pynobo_nobo.Model( diff --git a/tests/components/nobo_hub/snapshots/test_diagnostics.ambr b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr index 72d02fa6421e..e0da00274401 100644 --- a/tests/components/nobo_hub/snapshots/test_diagnostics.ambr +++ b/tests/components/nobo_hub/snapshots/test_diagnostics.ambr @@ -44,6 +44,7 @@ 'zones': dict({ '1': dict({ 'name': 'Living room', + 'override_allowed': '1', 'temp_comfort_c': '21', 'temp_eco_c': '17', 'week_profile_id': '0', diff --git a/tests/components/nobo_hub/snapshots/test_switch.ambr b/tests/components/nobo_hub/snapshots/test_switch.ambr new file mode 100644 index 000000000000..020db2063bfc --- /dev/null +++ b/tests/components/nobo_hub/snapshots/test_switch.ambr @@ -0,0 +1,51 @@ +# serializer version: 1 +# name: test_switch_entities[switch.living_room_living_room_disable_global_overrides-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.living_room_living_room_disable_global_overrides', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Disable global overrides', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Disable global overrides', + 'platform': 'nobo_hub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'disable_global_override', + 'unique_id': '102000013098:1:disable_global_override', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.living_room_living_room_disable_global_overrides-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Living room Disable global overrides', + }), + 'context': , + 'entity_id': 'switch.living_room_living_room_disable_global_overrides', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/nobo_hub/test_init.py b/tests/components/nobo_hub/test_init.py index e9cf6ee2a542..2b2233187871 100644 --- a/tests/components/nobo_hub/test_init.py +++ b/tests/components/nobo_hub/test_init.py @@ -443,7 +443,7 @@ async def test_disconnected_hub_does_not_remove_devices( @pytest.mark.parametrize( "platforms", - [[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR]], + [[Platform.CLIMATE, Platform.SELECT, Platform.SENSOR, Platform.SWITCH]], indirect=True, ) @pytest.mark.usefixtures("init_integration") diff --git a/tests/components/nobo_hub/test_switch.py b/tests/components/nobo_hub/test_switch.py new file mode 100644 index 000000000000..0883ac4a0867 --- /dev/null +++ b/tests/components/nobo_hub/test_switch.py @@ -0,0 +1,170 @@ +"""Tests for the Nobø Ecohub switch platform.""" + +from unittest.mock import MagicMock + +from pynobo import PynoboError, nobo +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.nobo_hub.const import DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from . import entity_unique_ids, fire_hub_update +from .conftest import SERIAL + +from tests.common import MockConfigEntry, snapshot_platform + +SWITCH_ENTITY = "switch.living_room_living_room_disable_global_overrides" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Only set up the switch platform for these tests.""" + return [Platform.SWITCH] + + +@pytest.mark.usefixtures("init_integration") +async def test_switch_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, +) -> None: + """All switch entities match their snapshot.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +@pytest.mark.parametrize( + ("service", "expected_override_allowed"), + [ + (SERVICE_TURN_ON, nobo.API.OVERRIDE_NOT_ALLOWED), + (SERVICE_TURN_OFF, nobo.API.OVERRIDE_ALLOWED), + ], + ids=["turn_on_disables", "turn_off_enables"], +) +@pytest.mark.usefixtures("init_integration") +async def test_switch_updates_zone( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + service: str, + expected_override_allowed: str, +) -> None: + """Toggling the switch updates the zone's override_allowed setting.""" + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: SWITCH_ENTITY}, + blocking=True, + ) + mock_nobo_hub.async_update_zone.assert_called_once_with( + "1", override_allowed=expected_override_allowed + ) + + +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +@pytest.mark.usefixtures("init_integration") +async def test_switch_wraps_library_error( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + service: str, +) -> None: + """Library errors during toggling are raised as HomeAssistantError.""" + mock_nobo_hub.async_update_zone.side_effect = PynoboError("boom") + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: SWITCH_ENTITY}, + blocking=True, + ) + assert exc_info.value.translation_domain == DOMAIN + assert exc_info.value.translation_key == "set_disable_global_override_failed" + + +@pytest.mark.usefixtures("init_integration") +async def test_switch_push_update( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Pushed hub updates refresh the switch state.""" + assert hass.states.get(SWITCH_ENTITY).state == STATE_OFF + + mock_nobo_hub.zones["1"]["override_allowed"] = nobo.API.OVERRIDE_NOT_ALLOWED + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(SWITCH_ENTITY).state == STATE_ON + + +@pytest.mark.usefixtures("init_integration") +async def test_zone_removed_removes_switch( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, +) -> None: + """Removing a zone via the Nobø app removes its switch.""" + mock_nobo_hub.zones.pop("1") + await fire_hub_update(hass, mock_nobo_hub) + assert hass.states.get(SWITCH_ENTITY) is None + + +@pytest.mark.usefixtures("init_integration") +async def test_new_zone_adds_switch( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone added on the hub at runtime creates a switch.""" + entry_id = mock_config_entry.entry_id + unique_id = f"{SERIAL}:2:disable_global_override" + assert unique_id not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + "override_allowed": "1", + } + await fire_hub_update(hass, mock_nobo_hub) + + assert unique_id in entity_unique_ids(entity_registry, entry_id) + + +@pytest.mark.usefixtures("init_integration") +async def test_readded_zone_reappears_switch( + hass: HomeAssistant, + mock_nobo_hub: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """A zone removed and re-added under the same id (the hub reuses ids) restores its switch.""" + entry_id = mock_config_entry.entry_id + unique_id = f"{SERIAL}:2:disable_global_override" + zone = { + "zone_id": "2", + "name": "Bedroom", + "week_profile_id": "0", + "temp_comfort_c": "22", + "temp_eco_c": "18", + "override_allowed": "1", + } + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert unique_id in entity_unique_ids(entity_registry, entry_id) + + del mock_nobo_hub.zones["2"] + await fire_hub_update(hass, mock_nobo_hub) + assert unique_id not in entity_unique_ids(entity_registry, entry_id) + + mock_nobo_hub.zones["2"] = zone + await fire_hub_update(hass, mock_nobo_hub) + assert unique_id in entity_unique_ids(entity_registry, entry_id)