Add per-zone global override switch to nobo_hub (#177772)

This commit is contained in:
Øyvind Matheson Wergeland
2026-08-30 09:16:28 +02:00
committed by GitHub
parent 82dab75ec5
commit 2fe8e77273
11 changed files with 354 additions and 7 deletions
@@ -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]
@@ -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"
@@ -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"
}
}
}
}
}
@@ -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
@@ -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."
},
+111
View File
@@ -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
)
+1
View File
@@ -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(
@@ -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',
@@ -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': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.living_room_living_room_disable_global_overrides',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'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({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living room Disable global overrides',
}),
'context': <ANY>,
'entity_id': 'switch.living_room_living_room_disable_global_overrides',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
+1 -1
View File
@@ -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")
+170
View File
@@ -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)