From a09b33ab9e76e9d7ea5a47ce703207ed1590b6e3 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Mon, 7 Sep 2026 08:37:14 +0200 Subject: [PATCH] Fix delayed Duco bypass target updates (#181367) --- homeassistant/components/duco/number.py | 15 ++++- tests/components/duco/conftest.py | 8 +-- tests/components/duco/test_number.py | 90 +++++++++++++++++++++---- 3 files changed, 94 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/duco/number.py b/homeassistant/components/duco/number.py index dcb87784a45a..6ba92ee78c8b 100644 --- a/homeassistant/components/duco/number.py +++ b/homeassistant/components/duco/number.py @@ -1,5 +1,6 @@ """Number platform for the Duco integration.""" +from dataclasses import replace import logging from typing import override @@ -129,7 +130,7 @@ class DucoBypassSupplyTemperatureTargetNumber(DucoEntity, NumberEntity): try: if self.unit_of_measurement != self.native_unit_of_measurement: value = target.normalize_value(value) - await self.coordinator.client.async_set_bypass_supply_temperature_target( + updated_target = await self.coordinator.client.async_set_bypass_supply_temperature_target( self._zone_id, value, target=target ) except ValueError as err: @@ -157,4 +158,14 @@ class DucoBypassSupplyTemperatureTargetNumber(DucoEntity, NumberEntity): translation_key="failed_to_set_bypass_supply_temperature_target", ) from err - await self.coordinator.async_request_refresh() + # Do not let a completed write mask a concurrent coordinator refresh failure. + if self.coordinator.last_update_success: + self.coordinator.async_set_updated_data( + replace( + self.coordinator.data, + bypass_supply_temperature_targets={ + **self.coordinator.data.bypass_supply_temperature_targets, + self._zone_id: updated_target, + }, + ) + ) diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 8dbf86d35fdf..d82b155ddc87 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -282,11 +282,11 @@ def mock_duco_client( temperature: float, *, target: BypassSupplyTemperatureTarget, - ) -> None: + ) -> BypassSupplyTemperatureTarget: target.validate_value(temperature) - mock_bypass_supply_temperature_targets[zone_id] = replace( - target, value=temperature - ) + updated_target = replace(target, zone_id=zone_id, value=temperature) + mock_bypass_supply_temperature_targets[zone_id] = updated_target + return updated_target with ( patch( diff --git a/tests/components/duco/test_number.py b/tests/components/duco/test_number.py index a5c2f9e273df..8f59182f4659 100644 --- a/tests/components/duco/test_number.py +++ b/tests/components/duco/test_number.py @@ -1,7 +1,8 @@ """Tests for the Duco number platform.""" +import asyncio from dataclasses import replace -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, call from duco_connectivity import ( BypassSupplyTemperatureTarget, @@ -66,6 +67,61 @@ async def test_bypass_supply_temperature_target_numbers_support_all_exposed_zone mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() +async def test_successful_write_does_not_recover_failed_coordinator( + hass: HomeAssistant, + mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, +) -> None: + """Test a successful write does not recover a failed coordinator.""" + await setup_platform_integration(hass, mock_config_entry, [Platform.NUMBER]) + write_started = asyncio.Event() + release_write = asyncio.Event() + target = mock_bypass_supply_temperature_targets[1] + + async def set_bypass_supply_temperature_target( + zone_id: int, + temperature: float, + *, + target: BypassSupplyTemperatureTarget, + ) -> BypassSupplyTemperatureTarget: + updated_target = replace(target, zone_id=zone_id, value=temperature) + mock_bypass_supply_temperature_targets[zone_id] = updated_target + write_started.set() + await release_write.wait() + return updated_target + + mock_duco_client.async_set_bypass_supply_temperature_target.side_effect = ( + set_bypass_supply_temperature_target + ) + write_task = asyncio.create_task( + hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, + blocking=True, + ) + ) + await write_started.wait() + + mock_duco_client.async_get_nodes.side_effect = DucoError("Temporary update failure") + await mock_config_entry.runtime_data.async_refresh() + + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + release_write.set() + await write_task + + mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( + 1, 20.5, target=target + ) + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNAVAILABLE + + @pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") async def test_bypass_supply_temperature_target_number_entities_state( hass: HomeAssistant, @@ -98,22 +154,30 @@ async def test_set_bypass_supply_temperature_target( mock_bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget], mock_duco_client: AsyncMock, ) -> None: - """Test setting a bypass target refreshes the number from the box.""" + """Test consecutive bypass target writes update directly from their responses.""" target = mock_bypass_supply_temperature_targets[1] - await hass.services.async_call( - NUMBER_DOMAIN, - SERVICE_SET_VALUE, - {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": 20.5}, - blocking=True, - ) + for value in (20.5, 21.0): + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: _ZONE_1_ENTITY_ID, "value": value}, + blocking=True, + ) - mock_duco_client.async_set_bypass_supply_temperature_target.assert_awaited_once_with( - 1, 20.5, target=target + state = hass.states.get(_ZONE_1_ENTITY_ID) + assert state is not None + assert state.state == str(value) + + assert ( + mock_duco_client.async_set_bypass_supply_temperature_target.await_args_list + == [ + call(1, 20.5, target=target), + call(1, 21.0, target=replace(target, value=20.5)), + ] ) - state = hass.states.get(_ZONE_1_ENTITY_ID) - assert state is not None - assert state.state == "20.5" + mock_duco_client.async_get_bypass_supply_temperature_targets.assert_awaited_once_with() + assert mock_bypass_supply_temperature_targets[1] == replace(target, value=21.0) async def test_set_bypass_supply_temperature_target_honors_increment_metadata(