8000 Add nobo_hub dynamic temperature controls by lersveen · Pull Request #145157 · home-assistant/core · GitHub
[go: up one dir, main page]

Skip to content

Add nobo_hub dynamic temperature controls #145157

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 27 commits into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
60df42d
Add option to disable comfort control for Nobo Hub
lersveen May 14, 2025
9884c84
Set support flags
lersveen May 15, 2025
f795b27
Fix temperature state
lersveen May 18, 2025
0106472
Use correct temperature attribute
lersveen May 18, 2025
1c47397
Set correct max temperature
lersveen May 18, 2025
d78b776
Update option descriptions
lersveen May 18, 2025
e3f4403
Ruff format
lersveen May 18, 2025
2a97d9c
Sort constants
lersveen May 18, 2025
8f055eb
Merge branch 'dev' into add-nobo-option
lersveen May 18, 2025
77aa25a
Merge branch 'add-nobo-option' of github.com:lersveen/core into add-n…
lersveen May 26, 2025
f0de02c
Set climate features dynamically from zone components
lersveen May 26, 2025
6b7eb38
Merge branch 'dev' into add-nobo-option
lersveen May 26, 2025
e5cb723
Add test for nobo_hub climate entity
lersveen May 26, 2025
6bc5222
Merge branch 'dev' into add-nobo-option
lersveen May 26, 2025
06ebf21
Merge branch 'dev' into add-nobo-option
lersveen May 27, 2025
1538152
Ensure parent attribute is set
lersveen May 27, 2025
2f5c336
Revert bugfix to be submitted separately
lersveen May 27, 2025
66e4e9c
Merge branch 'dev' into add-nobo-option
lersveen May 27, 2025
8ad7c47
Fix feature flag conditionals
lersveen May 28, 2025
10ded65
Merge branch 'dev' into add-nobo-option
lersveen May 28, 2025
4da3899
Merge branch 'dev' into add-nobo-option
lersveen May 29, 2025
9fdc89f
Add tests for async_set_temperature
lersveen May 30, 2025
3c3e414
Merge branch 'add-nobo-option' of github.com:lersveen/core into add-n…
lersveen May 30, 2025
1a65680
Revert unrelated changes
lersveen May 30, 2025
28526ea
Use properties for feature support calculations
lersveen May 30, 2025
803ab8b
Merge branch 'dev' into add-nobo-option
joostlek Jul 24, 2025
a05a5ca
Improve tests
joostlek Jul 24, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 65 additions & 17 deletions homeassistant/components/nobo_hub/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from homeassistant.components.climate import (
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
ATTR_TEMPERATURE,
PRESET_AWAY,
PRESET_COMFORT,
PRESET_ECO,
10000 Expand All @@ -33,10 +34,6 @@
OVERRIDE_TYPE_NOW,
)

SUPPORT_FLAGS = (
ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)

PRESET_MODES = [PRESET_NONE, PRESET_COMFORT, PRESET_ECO, PRESET_AWAY]

MIN_TEMPERATURE = 7
Expand Down Expand Up @@ -78,7 +75,6 @@ class NoboZone(ClimateEntity):
_attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO]
_attr_hvac_mode = HVACMode.AUTO
_attr_preset_modes = PRESET_MODES
_attr_supported_features = SUPPORT_FLAGS
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_target_temperature_step = 1
# Need to poll to get preset change when in HVACMode.AUTO, so can't set _attr_should_poll = False
Expand All @@ -95,8 +91,37 @@ def __init__(self, zone_id, hub: nobo, override_type) -> None:
via_device=(DOMAIN, hub.hub_info[ATTR_SERIAL]),
suggested_area=hub.zones[zone_id][ATTR_NAME],
)
self._supports_temp_modification = self.supports_temp_modification
self._attr_supported_features = self.supported_features
self._read_state()

@property
def supports_temp_modification(self) -> dict[str, bool]:
"""Determine which presets support temperature modification by checking zone components."""
supports_modification = {
nobo.API.NAME_COMFORT: False,
nobo.API.NAME_ECO: False,
}
for component in self._nobo.components.values():
if component["zone_id"] == self._id:
supports_modification[nobo.API.NAME_COMFORT] |= component[
"model"
].supports_comfort
supports_modification[nobo.API.NAME_ECO] |= component[
"model"
].supports_eco
return supports_modification

@property
def supported_features(self) -> ClimateEntityFeature:
"""Determine supported climate entity features."""
supported_features = ClimateEntityFeature.PRESET_MODE
if all(self._supports_temp_modification):
supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
elif any(self._supports_temp_modification):
supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
return supported_features

async def async_added_to_hass(self) -> None:
"""Register callback from hub."""
self._nobo.register_callback(self._after_update)
Expand Down Expand Up @@ -136,14 +161,23 @@ async def async_set_preset_mode(self, preset_mode: str) -> None:

async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
if ATTR_TARGET_TEMP_LOW in kwargs:
temp_args = {}
if ATTR_TEMPERATURE in kwargs:
temp = round(kwargs[ATTR_TEMPERATURE])
if self._supports_temp_modification[nobo.API.NAME_COMFORT]:
temp_args = {"temp_comfort_c": temp}
else:
temp_args = {"temp_eco_c": temp}
elif ATTR_TARGET_TEMP_LOW in kwargs:
low = round(kwargs[ATTR_TARGET_TEMP_LOW])
high = round(kwargs[ATTR_TARGET_TEMP_HIGH])
low = min(low, high)
high = max(low, high)
await self._nobo.async_update_zone(
self._id, temp_comfort_c=high, temp_eco_c=low
)
temp_args = {
"temp_comfort_c": high,
"temp_eco_c": low,
}

if temp_args:
await self._nobo.async_update_zone(self._id, **temp_args)

async def async_update(self) -> None:
"""Fetch new state data for this zone."""
Expand Down Expand Up @@ -172,12 +206,26 @@ def _read_state(self) -> None:
self._attr_current_temperature = (
None if current_temperature is None else float(current_temperature)
)
self._attr_target_temperature_high = int(
self._nobo.zones[self._id][ATTR_TEMP_COMFORT_C]
)
self._attr_target_temperature_low = int(
self._nobo.zones[self._id][ATTR_TEMP_ECO_C]
)

if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
self._attr_target_temperature_high = None
self._attr_target_temperature_low = None
if self._supports_temp_modification[nobo.API.NAME_COMFORT]:
temp_attr = ATTR_TEMP_COMFORT_C
else:
temp_attr = ATTR_TEMP_ECO_C
self._attr_target_temperature = int(self._nobo.zones[self._id][temp_attr])
elif (
self._attr_supported_features
& ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
):
self._attr_target_temperature_high = int(
self._nobo.zones[self._id][ATTR_TEMP_COMFORT_C]
)
self._attr_target_temperature_low = int(
self._nobo.zones[self._id][ATTR_TEMP_ECO_C]
)
self._attr_target_temperature = None

@callback
def _after_update(self, hub):
Expand Down
2 changes: 1 addition & 1 deletion homeassistant/components/nobo_hub/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ async def async_step_init(self, user_input=None) -> ConfigFlowResult:
{
vol.Required(CONF_OVERRIDE_TYPE, default=override_type): vol.In(
[OVERRIDE_TYPE_CONSTANT, OVERRIDE_TYPE_NOW]
),
)
}
)

Expand Down
12 changes: 12 additions & 0 deletions tests/components/nobo_hub/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,13 @@
"""Tests for the Nobø Ecohub integration."""

from homeassistant.core import HomeAssistant

from tests.common import MockConfigEntry


async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Fixture for setting up the component."""
config_entry.add_to_hass(hass)

await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
66 changes: 66 additions & 0 deletions tests/components/nobo_hub/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Fixtures for the Nobo Hub component tests."""

from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, patch

import pytest

from homeassistant.components.nobo_hub import CONF_AUTO_DISCOVERED, CONF_SERIAL, DOMAIN
from homeassistant.components.nobo_hub.const import (
ATTR_SERIAL,
ATTR_TEMP_COMFORT_C,
ATTR_TEMP_ECO_C,
)
from homeassistant.const import CONF_IP_ADDRESS

from tests.common import MockConfigEntry


@pytest.fixture
async def mock_nobo_hub() -> AsyncGenerator[AsyncMock]:
"""Fixture to mock the nobo hub."""
with patch("homeassistant.components.nobo_hub.nobo", autospec=True) as mock_nobo:
nobo = mock_nobo.return_value
nobo.zones = {
"device_1": {
"name": "Device 1",
"zone_id": "zone_1",
"model": nobo.MODELS["168"],
ATTR_TEMP_COMFORT_C: 22.0,
ATTR_TEMP_ECO_C: 18.0,
},
"device_2": {
"name": "Device 2",
"zone_id": "zone_1",
"model": nobo.MODELS["180"],
ATTR_TEMP_COMFORT_C: 22.0,
ATTR_TEMP_ECO_C: 18.0,
},
}
nobo.hub_serial = "218886794"
nobo.hub_info = {
ATTR_SERIAL: "218886794",
}
nobo.components = {
"component_1": {
"serial": "abc",
"status": "online",
"zone_id": "zone_1",
},
}
yield nobo


@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock a config entry."""
return MockConfigEntry(
domain=DOMAIN,
title="Test",
data={
CONF_SERIAL: "218886794",
CONF_IP_ADDRESS: "10.0.0.1",
CONF_AUTO_DISCOVERED: True,
},
unique_id="218886794",
)
161 changes: 161 additions & 0 deletions tests/components/nobo_hub/snapshots/test_climate.ambr
6FDD
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# serializer version: 1
# name: test_all_entities[climate.device_1-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30,
'min_temp': 7,
'preset_modes': list([
'none',
'comfort',
'eco',
'away',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.device_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'nobo_hub',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 18>,
'translation_key': None,
'unique_id': '218886794:device_1',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[climate.device_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 1.0,
'friendly_name': 'Device 1',
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30,
'min_temp': 7,
'preset_mode': 'none',
'preset_modes': list([
'none',
'comfort',
'eco',
'away',
]),
'supported_features': <ClimateEntityFeature: 18>,
'target_temp_high': 22,
'target_temp_low': 18,
'target_temp_step': 1,
}),
'context': <ANY>,
'entity_id': 'climate.device_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
# name: test_all_entities[climate.device_2-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30,
'min_temp': 7,
'preset_modes': list([
'none',
'comfort',
'eco',
'away',
]),
'target_temp_step': 1,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'climate',
'entity_category': None,
'entity_id': 'climate.device_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': None,
'platform': 'nobo_hub',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <ClimateEntityFeature: 18>,
'translation_key': None,
'unique_id': '218886794:device_2',
'unit_of_measurement': None,
})
# ---
# name: test_all_entities[climate.device_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_temperature': 1.0,
'friendly_name': 'Device 2',
'hvac_modes': list([
<HVACMode.HEAT: 'heat'>,
<HVACMode.AUTO: 'auto'>,
]),
'max_temp': 30,
'min_temp': 7,
'preset_mode': 'none',
'preset_modes': list([
'none',
'comfort',
'eco',
'away',
]),
'supported_features': <ClimateEntityFeature: 18>,
'target_temp_high': 22,
'target_temp_low': 18,
'target_temp_step': 1,
}),
'context': <ANY>,
'entity_id': 'climate.device_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'heat',
})
# ---
Loading
0