mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add per-zone global override switch to nobo_hub (#177772)
This commit is contained in:
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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',
|
||||
})
|
||||
# ---
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user