From 7ce4e1479cc19d6e59cf669d0a6319e28bae5916 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Sun, 30 Aug 2026 11:54:33 +0200 Subject: [PATCH] Add Duco bypass target temperature controls (#176820) Co-authored-by: Erwin Douna --- homeassistant/components/duco/const.py | 2 +- homeassistant/components/duco/coordinator.py | 26 ++ homeassistant/components/duco/number.py | 188 ++++++++++ homeassistant/components/duco/strings.json | 11 + tests/components/duco/conftest.py | 40 +++ .../duco/snapshots/test_number.ambr | 123 +++++++ tests/components/duco/test_init.py | 110 ++++++ tests/components/duco/test_number.py | 337 ++++++++++++++++++ 8 files changed, 836 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/duco/number.py create mode 100644 tests/components/duco/snapshots/test_number.ambr create mode 100644 tests/components/duco/test_number.py diff --git a/homeassistant/components/duco/const.py b/homeassistant/components/duco/const.py index 74cddde6642b..e62921a958a1 100644 --- a/homeassistant/components/duco/const.py +++ b/homeassistant/components/duco/const.py @@ -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, ...] = ( diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index e0755b153f09..a07aa2501a13 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -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, ) diff --git a/homeassistant/components/duco/number.py b/homeassistant/components/duco/number.py new file mode 100644 index 000000000000..c513aabd1a6a --- /dev/null +++ b/homeassistant/components/duco/number.py @@ -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() diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index 4f5eb782f93a..85c0130db083 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -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." }, diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 655dd0dfc935..d92956d82b89 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -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") ] diff --git a/tests/components/duco/snapshots/test_number.ambr b/tests/components/duco/snapshots/test_number.ambr new file mode 100644 index 000000000000..cea66a373024 --- /dev/null +++ b/tests/components/duco/snapshots/test_number.ambr @@ -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({ + : 25.0, + : 15.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_bypass_target_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bypass target 1', + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Bypass target 1', + : 25.0, + : 15.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.living_bypass_target_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + : 25.0, + : 15.0, + : , + : 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.living_bypass_target_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Bypass target 2', + 'options': dict({ + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_bypass_supply_temperature_target_number_entities_state[number.living_bypass_target_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Bypass target 2', + : 25.0, + : 15.0, + : , + : 0.1, + : , + }), + 'context': , + 'entity_id': 'number.living_bypass_target_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '21.0', + }) +# --- diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 9d13f3abf7e1..703510838695 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -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") ] diff --git a/tests/components/duco/test_number.py b/tests/components/duco/test_number.py new file mode 100644 index 000000000000..5f910402520d --- /dev/null +++ b/tests/components/duco/test_number.py @@ -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, + )