mirror of
https://github.com/home-assistant/core.git
synced 2026-09-24 23:41:48 -05:00
Add Duco bypass target temperature controls (#176820)
Co-authored-by: Erwin Douna <e.douna@gmail.com>
This commit is contained in:
co-authored by
Erwin Douna
parent
6bd4890f23
commit
7ce4e1479c
@@ -7,7 +7,7 @@ from duco_connectivity.models import NodeType
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "duco"
|
||||
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
|
||||
PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SELECT, Platform.SENSOR]
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
BOX_NODE_ID = 1
|
||||
VENTILATION_CAPABLE_NODE_TYPES: tuple[NodeType, ...] = (
|
||||
|
||||
@@ -14,6 +14,7 @@ from duco_connectivity.exceptions import (
|
||||
)
|
||||
from duco_connectivity.models import (
|
||||
BoardInfo,
|
||||
BypassSupplyTemperatureTarget,
|
||||
Node,
|
||||
NodeListActionItemList,
|
||||
NodeName,
|
||||
@@ -30,6 +31,7 @@ from .validation import UnsupportedBoardError, async_get_supported_board_info
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
|
||||
|
||||
|
||||
@@ -42,6 +44,7 @@ class DucoData:
|
||||
rssi_wifi: int | None
|
||||
time_filter_remain: int | None
|
||||
ventilation_temperatures: VentilationTemperatureInfo | None
|
||||
bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget]
|
||||
|
||||
|
||||
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
@@ -51,6 +54,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
board_info: BoardInfo
|
||||
_supports_time_filter_remain: bool
|
||||
_supports_ventilation_temperatures: bool
|
||||
_supports_bypass_supply_temperature_targets: bool
|
||||
_configured_node_names: dict[int, str]
|
||||
|
||||
def __init__(
|
||||
@@ -71,6 +75,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
self._configured_node_names = {}
|
||||
self._supports_time_filter_remain = True
|
||||
self._supports_ventilation_temperatures = True
|
||||
self._supports_bypass_supply_temperature_targets = True
|
||||
|
||||
async def _async_load_node_names(self) -> None:
|
||||
"""Load configured Duco node names during setup."""
|
||||
@@ -201,10 +206,31 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"Could not fetch Duco ventilation temperatures", exc_info=err
|
||||
)
|
||||
|
||||
bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget] = {}
|
||||
if self._supports_bypass_supply_temperature_targets:
|
||||
try:
|
||||
bypass_supply_temperature_targets = (
|
||||
await self.client.async_get_bypass_supply_temperature_targets()
|
||||
)
|
||||
except DucoUnsupportedCapabilityError:
|
||||
bypass_supply_temperature_targets = {}
|
||||
self._supports_bypass_supply_temperature_targets = False
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
) from err
|
||||
|
||||
return DucoData(
|
||||
nodes={node.node_id: node for node in nodes},
|
||||
node_actions=node_actions,
|
||||
rssi_wifi=rssi_wifi,
|
||||
time_filter_remain=time_filter_remain,
|
||||
ventilation_temperatures=ventilation_temperatures,
|
||||
bypass_supply_temperature_targets=bypass_supply_temperature_targets,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Number platform for the Duco integration."""
|
||||
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, Decimal
|
||||
import logging
|
||||
from typing import override
|
||||
|
||||
from duco_connectivity import DucoError, DucoRateLimitError
|
||||
from duco_connectivity.models import Node
|
||||
|
||||
from homeassistant.components.number import (
|
||||
NumberDeviceClass,
|
||||
NumberEntity,
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import EntityCategory, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import BOX_NODE_ID, DOMAIN
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = (
|
||||
NumberEntityDescription(
|
||||
key="bypass_supply_target_temperature_zone",
|
||||
translation_key="bypass_supply_target_temperature_zone",
|
||||
device_class=NumberDeviceClass.TEMPERATURE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: DucoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Duco number entities."""
|
||||
coordinator = entry.runtime_data
|
||||
known_entities: set[tuple[str, int]] = set()
|
||||
|
||||
@callback
|
||||
def _async_add_new_entities() -> None:
|
||||
"""Add number entities for discovered bypass temperature targets."""
|
||||
new_entities = []
|
||||
targets = coordinator.data.bypass_supply_temperature_targets
|
||||
for description in NUMBER_DESCRIPTIONS:
|
||||
for zone_id, target in targets.items():
|
||||
if (description.key, zone_id) in known_entities:
|
||||
continue
|
||||
|
||||
# Skip incomplete metadata because guessing valid limits would expose an invalid control.
|
||||
if (
|
||||
target.minimum is None
|
||||
or target.maximum is None
|
||||
or target.increment is None
|
||||
):
|
||||
continue
|
||||
|
||||
known_entities.add((description.key, zone_id))
|
||||
new_entities.append(
|
||||
DucoBypassSupplyTemperatureTargetNumber(
|
||||
coordinator,
|
||||
coordinator.data.nodes[BOX_NODE_ID],
|
||||
description,
|
||||
zone_id,
|
||||
target.minimum,
|
||||
target.maximum,
|
||||
target.increment,
|
||||
)
|
||||
)
|
||||
|
||||
if new_entities:
|
||||
async_add_entities(new_entities)
|
||||
|
||||
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
|
||||
_async_add_new_entities()
|
||||
|
||||
|
||||
class DucoBypassSupplyTemperatureTargetNumber(DucoEntity, NumberEntity):
|
||||
"""Number entity for a zone's bypass supply temperature target."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: DucoCoordinator,
|
||||
node: Node,
|
||||
description: NumberEntityDescription,
|
||||
zone_id: int,
|
||||
minimum: float,
|
||||
maximum: float,
|
||||
increment: float,
|
||||
) -> None:
|
||||
"""Initialize the bypass supply temperature target number."""
|
||||
super().__init__(coordinator, node)
|
||||
self.entity_description = description
|
||||
self._zone_id = zone_id
|
||||
self._attr_translation_placeholders = {"zone": str(zone_id)}
|
||||
self._attr_unique_id = (
|
||||
f"{coordinator.config_entry.unique_id}_{node.node_id}_"
|
||||
f"{description.key}_{zone_id}"
|
||||
)
|
||||
# Duco reports these as capability bounds for the target control rather
|
||||
# than live state, so the number entity keeps them fixed after creation.
|
||||
self._attr_native_min_value = minimum
|
||||
self._attr_native_max_value = maximum
|
||||
self._attr_native_step = increment
|
||||
|
||||
@property
|
||||
@override
|
||||
def available(self) -> bool:
|
||||
"""Return True if the zone currently exposes a bypass target."""
|
||||
return (
|
||||
super().available
|
||||
and self._zone_id in self.coordinator.data.bypass_supply_temperature_targets
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def native_value(self) -> float | None:
|
||||
"""Return the current bypass supply temperature target."""
|
||||
target = self.coordinator.data.bypass_supply_temperature_targets.get(
|
||||
self._zone_id
|
||||
)
|
||||
return target.value if target else None
|
||||
|
||||
def _normalize_step_value(self, value: float) -> float:
|
||||
"""Normalize converted temperature values to the nearest supported native step."""
|
||||
if self.unit_of_measurement == self.native_unit_of_measurement:
|
||||
return value
|
||||
|
||||
# Home Assistant converts service values from the configured temperature
|
||||
# unit first, which can land between valid Duco Celsius increments.
|
||||
minimum = Decimal(str(self.native_min_value))
|
||||
step = Decimal(str(self.native_step))
|
||||
steps = ((Decimal(str(value)) - minimum) / step).to_integral_value(
|
||||
rounding=ROUND_HALF_UP
|
||||
)
|
||||
# Rounding up may overshoot when the range is not a whole number of steps.
|
||||
max_steps = (
|
||||
(Decimal(str(self.native_max_value)) - minimum) / step
|
||||
).to_integral_value(rounding=ROUND_DOWN)
|
||||
return float(minimum + (min(steps, max_steps) * step))
|
||||
|
||||
@override
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set the bypass supply temperature target."""
|
||||
value = self._normalize_step_value(value)
|
||||
if (
|
||||
(Decimal(str(value)) - Decimal(str(self.native_min_value)))
|
||||
/ Decimal(str(self.native_step))
|
||||
) % 1 != 0:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_bypass_supply_temperature_target_step",
|
||||
translation_placeholders={
|
||||
"value": str(value),
|
||||
"minimum": str(self.native_min_value),
|
||||
"increment": str(self.native_step),
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
await self.coordinator.client.async_set_bypass_supply_temperature_target(
|
||||
self._zone_id, value
|
||||
)
|
||||
except DucoRateLimitError as err:
|
||||
_LOGGER.warning(
|
||||
"Duco write rate limit exceeded for bypass target zone %s",
|
||||
self._zone_id,
|
||||
)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="rate_limit_exceeded",
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_set_bypass_supply_temperature_target",
|
||||
) from err
|
||||
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -48,6 +48,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"bypass_supply_target_temperature_zone": {
|
||||
"name": "Bypass target {zone}"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"ventilation_state": {
|
||||
"name": "Ventilation state",
|
||||
@@ -134,9 +139,15 @@
|
||||
"connection_error": {
|
||||
"message": "Could not connect to the Duco device."
|
||||
},
|
||||
"failed_to_set_bypass_supply_temperature_target": {
|
||||
"message": "Failed to set bypass supply target temperature."
|
||||
},
|
||||
"failed_to_set_state": {
|
||||
"message": "Failed to set ventilation state."
|
||||
},
|
||||
"invalid_bypass_supply_temperature_target_step": {
|
||||
"message": "The value {value} does not match the supported increment of {increment} starting at {minimum}."
|
||||
},
|
||||
"rate_limit_exceeded": {
|
||||
"message": "The Duco device has reached its daily write limit. Try again tomorrow."
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Fixtures for Duco tests."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
@@ -10,6 +11,7 @@ from duco_connectivity import (
|
||||
ApiEndpointInfo,
|
||||
ApiInfo,
|
||||
BoardInfo,
|
||||
BypassSupplyTemperatureTarget,
|
||||
ConfigNode,
|
||||
ConfigNodeOverview,
|
||||
ConfigValueString,
|
||||
@@ -190,6 +192,29 @@ def mock_ventilation_temperature_info() -> VentilationTemperatureInfo:
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_bypass_supply_temperature_targets() -> dict[
|
||||
int, BypassSupplyTemperatureTarget
|
||||
]:
|
||||
"""Return mock bypass supply temperature targets in Celsius."""
|
||||
return {
|
||||
1: BypassSupplyTemperatureTarget(
|
||||
zone_id=1,
|
||||
value=20.0,
|
||||
minimum=15.0,
|
||||
increment=0.1,
|
||||
maximum=25.0,
|
||||
),
|
||||
2: BypassSupplyTemperatureTarget(
|
||||
zone_id=2,
|
||||
value=21.0,
|
||||
minimum=15.0,
|
||||
increment=0.1,
|
||||
maximum=25.0,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_nodes() -> list[Node]:
|
||||
"""Return a list of nodes covering all supported types."""
|
||||
@@ -244,6 +269,7 @@ def dynamic_sensor_nodes() -> dict[int, Node]:
|
||||
def mock_duco_client(
|
||||
mock_api_info: ApiInfo,
|
||||
mock_board_info: BoardInfo,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_lan_info: LanInfo,
|
||||
mock_nodes: list[Node],
|
||||
mock_node_actions: NodeListActionItemList,
|
||||
@@ -271,6 +297,20 @@ def mock_duco_client(
|
||||
client.async_get_ventilation_temperature_info.return_value = (
|
||||
mock_ventilation_temperature_info
|
||||
)
|
||||
client.async_get_bypass_supply_temperature_targets.side_effect = (
|
||||
mock_bypass_supply_temperature_targets.copy
|
||||
)
|
||||
client.async_set_bypass_supply_temperature_target.side_effect = (
|
||||
lambda zone_id, temperature: (
|
||||
mock_bypass_supply_temperature_targets.__setitem__(
|
||||
zone_id,
|
||||
replace(
|
||||
mock_bypass_supply_temperature_targets[zone_id],
|
||||
value=temperature,
|
||||
),
|
||||
)
|
||||
)
|
||||
)
|
||||
client.async_get_diagnostics.return_value = [
|
||||
DiagComponent(component="Ventilation", status="Ok")
|
||||
]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
# serializer version: 1
|
||||
# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_1-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<NumberEntityCapabilityAttribute.MAX: 'max'>: 25.0,
|
||||
<NumberEntityCapabilityAttribute.MIN: 'min'>: 15.0,
|
||||
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
|
||||
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.living_bypass_target_1',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Bypass target 1',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Bypass target 1',
|
||||
'platform': 'duco',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'bypass_supply_target_temperature_zone',
|
||||
'unique_id': 'aa:bb:cc:dd:ee:ff_1_bypass_supply_target_temperature_zone_1',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_1-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Bypass target 1',
|
||||
<NumberEntityCapabilityAttribute.MAX: 'max'>: 25.0,
|
||||
<NumberEntityCapabilityAttribute.MIN: 'min'>: 15.0,
|
||||
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
|
||||
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.living_bypass_target_1',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '20.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_2-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
]),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
<NumberEntityCapabilityAttribute.MAX: 'max'>: 25.0,
|
||||
<NumberEntityCapabilityAttribute.MIN: 'min'>: 15.0,
|
||||
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
|
||||
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'number',
|
||||
'entity_category': <EntityCategory.CONFIG: 'config'>,
|
||||
'entity_id': 'number.living_bypass_target_2',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Bypass target 2',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Bypass target 2',
|
||||
'platform': 'duco',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'bypass_supply_target_temperature_zone',
|
||||
'unique_id': 'aa:bb:cc:dd:ee:ff_1_bypass_supply_target_temperature_zone_2',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_2-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
|
||||
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Bypass target 2',
|
||||
<NumberEntityCapabilityAttribute.MAX: 'max'>: 25.0,
|
||||
<NumberEntityCapabilityAttribute.MIN: 'min'>: 15.0,
|
||||
<NumberEntityCapabilityAttribute.MODE: 'mode'>: <NumberMode.AUTO: 'auto'>,
|
||||
<NumberEntityCapabilityAttribute.STEP: 'step'>: 0.1,
|
||||
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'number.living_bypass_target_2',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '21.0',
|
||||
})
|
||||
# ---
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import ANY, AsyncMock, patch
|
||||
|
||||
from duco_connectivity import (
|
||||
BoardInfo,
|
||||
BypassSupplyTemperatureTarget,
|
||||
ConfigNode,
|
||||
ConfigNodeOverview,
|
||||
ConfigValueString,
|
||||
@@ -12,6 +13,7 @@ from duco_connectivity import (
|
||||
DucoConnectionError,
|
||||
DucoError,
|
||||
DucoResponseError,
|
||||
DucoUnsupportedCapabilityError,
|
||||
LanInfo,
|
||||
Node,
|
||||
NodeListActionItemList,
|
||||
@@ -231,6 +233,104 @@ async def test_setup_entry_recovers_from_optional_temperature_capability_failure
|
||||
assert state.state == "5.5"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "translation_key"),
|
||||
[
|
||||
pytest.param(
|
||||
DucoConnectionError("Connection refused"),
|
||||
"cannot_connect",
|
||||
id="connection_error",
|
||||
),
|
||||
pytest.param(DucoError("API error"), "api_error", id="duco_error"),
|
||||
pytest.param(
|
||||
DucoResponseError(500, "/config"),
|
||||
"api_error",
|
||||
id="response_error",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_setup_entry_retries_on_bypass_temperature_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
exception: Exception,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Test setup retries when fetching bypass temperature targets fails."""
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = exception
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
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 == translation_key
|
||||
assert mock_config_entry.error_reason_translation_placeholders is None
|
||||
|
||||
|
||||
async def test_unsupported_bypass_temperature_capability_is_not_repolled(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test an unsupported bulk bypass target endpoint is not polled again."""
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = (
|
||||
DucoUnsupportedCapabilityError(
|
||||
400,
|
||||
"/config",
|
||||
'{"Code":3,"Result":"FAILED"}',
|
||||
)
|
||||
)
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
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 hass.states.get("number.living_bypass_target_1") is None
|
||||
assert hass.states.get("number.living_bypass_target_2") is None
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with()
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with()
|
||||
|
||||
|
||||
async def test_missing_bypass_temperature_targets_are_retried(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test missing bypass targets are retried and can later create entities."""
|
||||
targets_without_zone_1 = {
|
||||
k: v for k, v in mock_bypass_supply_temperature_targets.items() if k != 1
|
||||
}
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [
|
||||
targets_without_zone_1,
|
||||
mock_bypass_supply_temperature_targets.copy(),
|
||||
]
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
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 hass.states.get("number.living_bypass_target_1") is None
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get("number.living_bypass_target_1")
|
||||
assert state is not None
|
||||
assert state.state == "20.0"
|
||||
|
||||
|
||||
async def test_setup_entry_ignores_node_name_config_failures(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -356,6 +456,16 @@ async def test_setup_entry_creates_http_client(
|
||||
(
|
||||
mock_client_class.return_value.async_get_ventilation_temperature_info.return_value
|
||||
) = VentilationTemperatureInfo()
|
||||
|
||||
mock_client_class.return_value.async_get_bypass_supply_temperature_targets.return_value = {
|
||||
1: BypassSupplyTemperatureTarget(
|
||||
zone_id=1,
|
||||
value=20.0,
|
||||
minimum=15.0,
|
||||
increment=0.1,
|
||||
maximum=25.0,
|
||||
)
|
||||
}
|
||||
mock_client_class.return_value.async_get_diagnostics.return_value = [
|
||||
DiagComponent(component="Ventilation", status="Ok")
|
||||
]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for the Duco number platform."""
|
||||
|
||||
from dataclasses import replace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from duco_connectivity import (
|
||||
BypassSupplyTemperatureTarget,
|
||||
DucoError,
|
||||
DucoRateLimitError,
|
||||
)
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.duco.const import SCAN_INTERVAL
|
||||
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM
|
||||
|
||||
from . import setup_platform_integration
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
_ZONE_1_ENTITY_ID = "number.living_bypass_target_1"
|
||||
_ZONE_2_ENTITY_ID = "number.living_bypass_target_2"
|
||||
_ZONE_8_ENTITY_ID = "number.living_bypass_target_8"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up only the number platform for testing."""
|
||||
return await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
|
||||
async def test_bypass_supply_temperature_target_numbers_support_all_exposed_zones(
|
||||
hass: HomeAssistant,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test bypass target controls are created for all exposed zones."""
|
||||
mock_bypass_supply_temperature_targets[8] = BypassSupplyTemperatureTarget(
|
||||
zone_id=8,
|
||||
value=22.0,
|
||||
minimum=15.0,
|
||||
increment=0.1,
|
||||
maximum=25.0,
|
||||
)
|
||||
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
for entity_id in (
|
||||
_ZONE_1_ENTITY_ID,
|
||||
_ZONE_2_ENTITY_ID,
|
||||
_ZONE_8_ENTITY_ID,
|
||||
):
|
||||
assert hass.states.get(entity_id) is not None
|
||||
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
|
||||
async def test_bypass_supply_temperature_target_number_entities_state(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test bypass supply temperature target number entity states."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_duco_client")
|
||||
async def test_bypass_supply_temperature_targets_missing_skips_number_creation(
|
||||
hass: HomeAssistant,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test no number entities are created when bypass targets are unavailable."""
|
||||
mock_bypass_supply_temperature_targets.clear()
|
||||
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
assert hass.states.get(_ZONE_1_ENTITY_ID) is None
|
||||
assert hass.states.get(_ZONE_2_ENTITY_ID) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
pytest.param("minimum", id="missing_minimum"),
|
||||
pytest.param("maximum", id="missing_maximum"),
|
||||
pytest.param("increment", id="missing_increment"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_duco_client")
|
||||
async def test_bypass_supply_temperature_target_incomplete_metadata_skips_number_creation(
|
||||
hass: HomeAssistant,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
field: str,
|
||||
) -> None:
|
||||
"""Test incomplete target metadata does not expose an invalid control."""
|
||||
mock_bypass_supply_temperature_targets[1] = replace(
|
||||
mock_bypass_supply_temperature_targets[1], **{field: None}
|
||||
)
|
||||
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
assert hass.states.get(_ZONE_1_ENTITY_ID) is None
|
||||
assert hass.states.get(_ZONE_2_ENTITY_ID) is not None
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_set_bypass_supply_temperature_target(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test setting a bypass target refreshes the number from the box."""
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
||||
1, 20.5
|
||||
)
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "20.5"
|
||||
|
||||
|
||||
async def test_set_bypass_supply_temperature_target_honors_increment_metadata(
|
||||
hass: HomeAssistant,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test bypass target writes follow the API-provided increment metadata."""
|
||||
mock_bypass_supply_temperature_targets[1] = replace(
|
||||
mock_bypass_supply_temperature_targets[1],
|
||||
minimum=10.0,
|
||||
increment=0.5,
|
||||
maximum=25.5,
|
||||
)
|
||||
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
||||
1, 20.5
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="supported increment of 0.5 starting at 10.0",
|
||||
):
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.2},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_set_bypass_supply_temperature_target_in_fahrenheit_units(
|
||||
hass: HomeAssistant,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test Fahrenheit service writes normalize to the nearest supported Celsius step."""
|
||||
hass.config.units = US_CUSTOMARY_SYSTEM
|
||||
mock_bypass_supply_temperature_targets[1] = replace(
|
||||
mock_bypass_supply_temperature_targets[1],
|
||||
minimum=10.0,
|
||||
increment=0.5,
|
||||
maximum=25.5,
|
||||
)
|
||||
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 69.0},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
||||
1, 20.5
|
||||
)
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "68.9"
|
||||
|
||||
|
||||
async def test_set_bypass_supply_temperature_target_stays_within_maximum(
|
||||
hass: HomeAssistant,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test normalization never rounds past a maximum that is not a whole step."""
|
||||
hass.config.units = US_CUSTOMARY_SYSTEM
|
||||
mock_bypass_supply_temperature_targets[1] = replace(
|
||||
mock_bypass_supply_temperature_targets[1],
|
||||
minimum=10.0,
|
||||
increment=0.5,
|
||||
maximum=24.8,
|
||||
)
|
||||
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 76.6},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with(
|
||||
1, 24.5
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_duco_client")
|
||||
async def test_bypass_supply_temperature_target_becomes_unavailable_when_missing(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test a bypass target becomes unavailable when a bulk read omits it."""
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "20.0"
|
||||
|
||||
updated_target = replace(mock_bypass_supply_temperature_targets.pop(1), value=20.5)
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
mock_bypass_supply_temperature_targets[1] = updated_target
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "20.5"
|
||||
|
||||
|
||||
async def test_bypass_supply_temperature_target_recovers_from_refresh_error(
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget],
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_duco_client: AsyncMock,
|
||||
) -> None:
|
||||
"""Test a bypass target recovers after a transient refresh error."""
|
||||
await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER])
|
||||
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "20.0"
|
||||
|
||||
mock_duco_client.async_get_bypass_supply_temperature_targets.side_effect = [
|
||||
DucoError("Temporary bypass target failure"),
|
||||
mock_bypass_supply_temperature_targets.copy(),
|
||||
]
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
freezer.tick(SCAN_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
state = hass.states.get(_ZONE_1_ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == "20.0"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@pytest.mark.parametrize(
|
||||
("exception", "match"),
|
||||
[
|
||||
pytest.param(
|
||||
DucoError("Unexpected error"),
|
||||
"Failed to set bypass supply target temperature",
|
||||
id="duco_error",
|
||||
),
|
||||
pytest.param(DucoRateLimitError(), "daily write limit", id="rate_limit"),
|
||||
],
|
||||
)
|
||||
async def test_set_bypass_supply_temperature_target_error(
|
||||
hass: HomeAssistant,
|
||||
mock_duco_client: AsyncMock,
|
||||
exception: Exception,
|
||||
match: str,
|
||||
) -> None:
|
||||
"""Test write failures raise translated Home Assistant errors."""
|
||||
mock_duco_client.async_set_bypass_supply_temperature_target.side_effect = exception
|
||||
|
||||
with pytest.raises(HomeAssistantError, match=match):
|
||||
await hass.services.async_call(
|
||||
NUMBER_DOMAIN,
|
||||
SERVICE_SET_VALUE,
|
||||
{ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5},
|
||||
blocking=True,
|
||||
)
|
||||
Reference in New Issue
Block a user