Fix delayed Duco node updates after ventilation changes (#181528)

This commit is contained in:
Ronald van der Meer
2026-09-08 11:04:05 +02:00
committed by GitHub
parent dd8fd40b3a
commit 62c3cc8871
6 changed files with 360 additions and 49 deletions
+54 -1
View File
@@ -1,11 +1,12 @@
"""Data update coordinator for the Duco integration."""
import asyncio
from contextlib import suppress
from dataclasses import dataclass, replace
import logging
from typing import cast, override
from duco_connectivity import DucoClient
from duco_connectivity import DucoClient, VentilationState
from duco_connectivity.exceptions import (
DucoConnectionError,
DucoError,
@@ -52,6 +53,9 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
config_entry: DucoConfigEntry
board_info: BoardInfo
_configured_node_names: dict[int, str]
_full_update_failed: bool
_node_update_errors: dict[int, DucoError]
_request_lock: asyncio.Lock
def __init__(
self,
@@ -69,6 +73,46 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
)
self.client = client
self._configured_node_names = {}
self._full_update_failed = False
self._node_update_errors = {}
self._request_lock = asyncio.Lock()
async def async_set_ventilation_state(
self, node_id: int, state: str | VentilationState
) -> None:
"""Set and refresh a node's ventilation state."""
# Keep an older read from publishing after this write completes.
async with self._request_lock:
await self.client.async_set_ventilation_state(node_id, state)
await self._async_refresh_node(node_id)
async def _async_refresh_node(self, node_id: int) -> None:
"""Refresh one node while holding the request lock."""
try:
node = await self.client.async_get_node_info(node_id)
except DucoError as err:
self._node_update_errors[node_id] = err
self.async_set_update_error(err)
return
if current_node := self.data.nodes.get(node_id):
node = replace(
node,
general=replace(
node.general,
name=current_node.general.name,
),
)
self._node_update_errors.pop(node_id, None)
self.data = replace(
self.data,
nodes={**self.data.nodes, node_id: node},
)
if not self._full_update_failed and not self._node_update_errors:
self.last_update_success = True
# A targeted readback must not postpone the periodic full refresh.
self.async_update_listeners()
async def _async_load_node_names(self) -> None:
"""Load configured Duco node names during setup."""
@@ -117,6 +161,15 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
@override
async def _async_update_data(self) -> DucoData:
"""Fetch node data from the Duco box."""
async with self._request_lock:
self._full_update_failed = True
data = await self._async_fetch_data()
self._full_update_failed = False
self._node_update_errors.clear()
return data
async def _async_fetch_data(self) -> DucoData:
"""Fetch node data while holding the request lock."""
try:
nodes = await self.client.async_get_nodes()
except DucoConnectionError as err:
+2 -5
View File
@@ -122,11 +122,9 @@ class DucoVentilationFanEntity(DucoEntity, FanEntity):
await self._async_set_state(state)
async def _async_set_state(self, state: VentilationState) -> None:
"""Send the ventilation state to the device and refresh coordinator."""
"""Set the ventilation state."""
try:
await self.coordinator.client.async_set_ventilation_state(
self._node_id, state
)
await self.coordinator.async_set_ventilation_state(self._node_id, state)
except DucoRateLimitError as err:
_LOGGER.warning("Duco write rate limit exceeded for node %s", self._node_id)
raise HomeAssistantError(
@@ -138,4 +136,3 @@ class DucoVentilationFanEntity(DucoEntity, FanEntity):
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
) from err
await self.coordinator.async_refresh()
+1 -7
View File
@@ -129,9 +129,7 @@ class DucoVentilationStateSelect(DucoEntity, SelectEntity):
try:
# SelectEntity exposes string options, and passing the raw API value
# through keeps newly added Duco states forward-compatible.
await self.coordinator.client.async_set_ventilation_state(
self._node_id, option
)
await self.coordinator.async_set_ventilation_state(self._node_id, option)
except DucoRateLimitError as err:
_LOGGER.warning("Duco write rate limit exceeded for node %s", self._node_id)
raise HomeAssistantError(
@@ -143,7 +141,3 @@ class DucoVentilationStateSelect(DucoEntity, SelectEntity):
translation_domain=DOMAIN,
translation_key="failed_to_set_state",
) from err
# Duco may normalize the requested action on readback, such as
# MAN1x2 -> MAN1 or AUTO -> CNT1, so refresh the authoritative state.
await self.coordinator.async_refresh()
+9
View File
@@ -299,10 +299,19 @@ def mock_duco_client(
),
):
client = mock_class.return_value
def get_node_info(node_id: int) -> Node:
return next(
node
for node in client.async_get_nodes.return_value
if node.node_id == node_id
)
client.async_get_api_info.return_value = mock_api_info
client.async_get_board_info.return_value = mock_board_info
client.async_get_lan_info.return_value = mock_lan_info
client.async_get_nodes.return_value = mock_nodes
client.async_get_node_info.side_effect = get_node_info
client.async_get_node_configs.return_value = node_configs_from_nodes(mock_nodes)
client.async_get_node_actions.return_value = mock_node_actions
client.async_get_time_filter_remaining.return_value = 180
+3 -3
View File
@@ -68,8 +68,6 @@ async def test_fan_set_state(
expected_duco_state: str,
) -> None:
"""Test that fan service calls map to the correct Duco ventilation state."""
mock_duco_client.async_set_ventilation_state = AsyncMock()
await hass.services.async_call(
FAN_DOMAIN,
service,
@@ -77,9 +75,11 @@ async def test_fan_set_state(
blocking=True,
)
mock_duco_client.async_set_ventilation_state.assert_called_once_with(
mock_duco_client.async_set_ventilation_state.assert_awaited_once_with(
1, expected_duco_state
)
mock_duco_client.async_get_node_info.assert_awaited_once_with(1)
assert mock_duco_client.async_get_nodes.await_count == 1
@pytest.mark.usefixtures("init_integration")
+291 -33
View File
@@ -1,5 +1,6 @@
"""Tests for the Duco select platform."""
import asyncio
from dataclasses import replace
from unittest.mock import AsyncMock
@@ -16,21 +17,33 @@ from duco_connectivity import (
NodeType,
VentilationState,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.duco.const import SCAN_INTERVAL
from homeassistant.components.fan import (
ATTR_PERCENTAGE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
)
from homeassistant.components.select import (
ATTR_OPTION,
ATTR_OPTIONS,
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from . import setup_platform_integration
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
_SELECT_ENTITY = "select.living_ventilation_state"
_VALVE_SELECT_ENTITY = "select.bedroom_valve_ventilation_state"
@@ -93,6 +106,35 @@ def _replace_node_state(node: Node, state: str | VentilationState | None) -> Nod
return replace(node, ventilation=replace(node.ventilation, state=state))
def _assert_select_state(hass: HomeAssistant, expected_state: str) -> None:
"""Assert the ventilation select state."""
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.state == expected_state
async def _async_select_option(
hass: HomeAssistant, option: str, entity_id: str = _SELECT_ENTITY
) -> None:
"""Select a ventilation option."""
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: option},
blocking=True,
)
async def _async_set_fan_percentage(hass: HomeAssistant) -> None:
"""Set the ventilation fan percentage."""
await hass.services.async_call(
FAN_DOMAIN,
SERVICE_SET_PERCENTAGE,
{ATTR_ENTITY_ID: "fan.living", ATTR_PERCENTAGE: 33},
blocking=True,
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
@@ -187,14 +229,28 @@ async def test_select_option_calls_ventilation_state_library_method(
"""Test that selecting an option uses the typed ventilation state helper."""
mock_duco_client.async_set_ventilation_state = AsyncMock()
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "CNT2"},
blocking=True,
)
await _async_select_option(hass, "CNT2")
mock_duco_client.async_set_ventilation_state.assert_called_once_with(1, "CNT2")
mock_duco_client.async_set_ventilation_state.assert_awaited_once_with(1, "CNT2")
mock_duco_client.async_get_node_info.assert_awaited_once_with(1)
assert mock_duco_client.async_get_nodes.await_count == 1
@pytest.mark.usefixtures("init_integration")
async def test_targeted_readback_keeps_coordinator_poll_schedule(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a targeted readback does not postpone the full coordinator poll."""
freezer.tick(SCAN_INTERVAL / 2)
await _async_select_option(hass, "CNT2")
freezer.tick(SCAN_INTERVAL / 2)
async_fire_time_changed(hass)
await hass.async_block_till_done()
assert mock_duco_client.async_get_nodes.await_count == 2
@pytest.mark.usefixtures("init_integration")
@@ -215,12 +271,7 @@ async def test_select_option_error(
mock_duco_client.async_set_ventilation_state = AsyncMock(side_effect=exception)
with pytest.raises(HomeAssistantError, match=match):
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "CNT2"},
blocking=True,
)
await _async_select_option(hass, "CNT2")
async def test_select_extended_manual_options_allow_normalized_readback(
@@ -238,7 +289,6 @@ async def test_select_extended_manual_options_allow_normalized_readback(
state = hass.states.get(_SELECT_ENTITY)
assert state is not None
assert state.attributes[ATTR_OPTIONS] == ["AUTO", "MAN1", "MAN1x2", "MAN1x3"]
box_node = mock_nodes[0]
mock_duco_client.async_set_ventilation_state = AsyncMock()
mock_duco_client.async_get_nodes.return_value = [
@@ -246,12 +296,7 @@ async def test_select_extended_manual_options_allow_normalized_readback(
*mock_nodes[1:],
]
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "MAN1x2"},
blocking=True,
)
await _async_select_option(hass, "MAN1x2")
mock_duco_client.async_set_ventilation_state.assert_called_once_with(1, "MAN1x2")
state = hass.states.get(_SELECT_ENTITY)
@@ -270,7 +315,6 @@ async def test_select_auto_option_allows_cnt1_readback(
options=["AUTO", "CNT1", "CNT2"]
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
box_node = mock_nodes[0]
mock_duco_client.async_set_ventilation_state = AsyncMock()
mock_duco_client.async_get_nodes.return_value = [
@@ -278,12 +322,7 @@ async def test_select_auto_option_allows_cnt1_readback(
*mock_nodes[1:],
]
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{ATTR_ENTITY_ID: _SELECT_ENTITY, ATTR_OPTION: "AUTO"},
blocking=True,
)
await _async_select_option(hass, "AUTO")
mock_duco_client.async_set_ventilation_state.assert_called_once_with(1, "AUTO")
state = hass.states.get(_SELECT_ENTITY)
@@ -291,10 +330,230 @@ async def test_select_auto_option_allows_cnt1_readback(
assert state.state == "CNT1"
@pytest.mark.parametrize(
("failed_entity_id", "expected_state"),
[
pytest.param(_SELECT_ENTITY, "CNT1", id="same-node"),
pytest.param(_VALVE_SELECT_ENTITY, STATE_UNAVAILABLE, id="other-node"),
],
)
async def test_targeted_readback_failure_and_recovery(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_sensor_nodes: list[Node],
failed_entity_id: str,
expected_state: str,
) -> None:
"""Test a targeted success only clears its own node's failure."""
mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes
mock_duco_client.async_get_node_actions.return_value = _build_multi_node_actions(
[1, 60], options=["AUTO", "CNT1", "MAN3"]
)
await setup_platform_integration(
hass, mock_config_entry, [Platform.FAN, Platform.SELECT]
)
readback_started = asyncio.Event()
release_readback = asyncio.Event()
readback_count = 0
async def get_node_info(node_id: int) -> Node:
nonlocal readback_count
readback_count += 1
if readback_count == 1:
readback_started.set()
await release_readback.wait()
raise DucoError("Readback failed")
return _replace_node_state(mock_sensor_nodes[0], "CNT1")
mock_duco_client.async_get_node_info.side_effect = get_node_info
failed_task = asyncio.create_task(
_async_select_option(hass, "MAN3", failed_entity_id)
)
await readback_started.wait()
fan_task = asyncio.create_task(_async_set_fan_percentage(hass))
release_readback.set()
await asyncio.gather(failed_task, fan_task)
_assert_select_state(hass, expected_state)
async def test_targeted_readback_restores_node_omitted_by_older_poll(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a targeted readback restores a node omitted by an older poll."""
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
poll_started = asyncio.Event()
release_poll = asyncio.Event()
write_started = asyncio.Event()
async def get_nodes() -> list[Node]:
poll_started.set()
await release_poll.wait()
return mock_nodes[1:]
def set_ventilation_state(node_id: int, state: str | VentilationState) -> None:
write_started.set()
mock_duco_client.async_get_nodes.side_effect = get_nodes
mock_duco_client.async_set_ventilation_state.side_effect = set_ventilation_state
mock_duco_client.async_get_node_info.return_value = _replace_node_state(
mock_nodes[0], "MAN3"
)
mock_duco_client.async_get_node_info.side_effect = None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await poll_started.wait()
write_task = asyncio.create_task(_async_select_option(hass, "MAN3"))
await asyncio.sleep(0)
assert not write_started.is_set()
release_poll.set()
await write_task
_assert_select_state(hass, "MAN3")
async def test_targeted_readback_does_not_recover_failed_coordinator_poll(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
freezer: FrozenDateTimeFactory,
) -> None:
"""Test a node readback does not recover a failed full coordinator poll."""
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
poll_started = asyncio.Event()
release_poll = asyncio.Event()
async def get_nodes() -> list[Node]:
poll_started.set()
await release_poll.wait()
raise DucoConnectionError("Connection failed")
mock_duco_client.async_get_nodes.side_effect = get_nodes
mock_duco_client.async_get_node_info.return_value = _replace_node_state(
mock_nodes[0], "MAN3"
)
mock_duco_client.async_get_node_info.side_effect = None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await poll_started.wait()
write_task = asyncio.create_task(_async_select_option(hass, "MAN3"))
await asyncio.sleep(0)
release_poll.set()
await write_task
await hass.async_block_till_done()
mock_duco_client.async_set_ventilation_state.assert_awaited_once_with(1, "MAN3")
mock_duco_client.async_get_node_info.assert_awaited_once_with(1)
_assert_select_state(hass, STATE_UNAVAILABLE)
async def test_latest_targeted_readback_wins_when_fan_and_select_overlap(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
) -> None:
"""Test an older fan readback cannot overwrite a newer select readback."""
await setup_platform_integration(
hass, mock_config_entry, [Platform.FAN, Platform.SELECT]
)
first_readback_started = asyncio.Event()
release_first_readback = asyncio.Event()
select_write_started = asyncio.Event()
readback_count = 0
def set_ventilation_state(node_id: int, state: str | VentilationState) -> None:
if state == "MAN3":
select_write_started.set()
async def get_node_info(node_id: int) -> Node:
nonlocal readback_count
readback_count += 1
if readback_count == 1:
first_readback_started.set()
await release_first_readback.wait()
return _replace_node_state(mock_nodes[0], "CNT1")
return _replace_node_state(mock_nodes[0], "MAN3")
mock_duco_client.async_set_ventilation_state.side_effect = set_ventilation_state
mock_duco_client.async_get_node_info.side_effect = get_node_info
fan_task = asyncio.create_task(_async_set_fan_percentage(hass))
await first_readback_started.wait()
select_task = asyncio.create_task(_async_select_option(hass, "MAN3"))
await asyncio.sleep(0)
assert not select_write_started.is_set()
release_first_readback.set()
await asyncio.gather(fan_task, select_task)
_assert_select_state(hass, "MAN3")
@pytest.mark.usefixtures("init_integration")
@pytest.mark.parametrize(
"readback_error",
[
pytest.param(None, id="success"),
pytest.param(
DucoError("Readback failed"),
id="failure",
),
],
)
async def test_newer_coordinator_poll_supersedes_targeted_outcome(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
mock_nodes: list[Node],
freezer: FrozenDateTimeFactory,
readback_error: DucoError | None,
) -> None:
"""Test a newer coordinator poll supersedes an older targeted outcome."""
readback_started = asyncio.Event()
release_readback = asyncio.Event()
async def get_node_info(node_id: int) -> Node:
readback_started.set()
await release_readback.wait()
if readback_error:
raise readback_error
return _replace_node_state(mock_nodes[0], "MAN3")
mock_duco_client.async_get_node_info.side_effect = get_node_info
write_task = asyncio.create_task(_async_select_option(hass, "MAN3"))
await readback_started.wait()
mock_duco_client.async_get_nodes.return_value = [
_replace_node_state(mock_nodes[0], "CNT2"),
*mock_nodes[1:],
]
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
release_readback.set()
await write_task
await hass.async_block_till_done()
_assert_select_state(hass, "CNT2")
async def test_select_entity_is_added_when_action_discovery_succeeds_later(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test select entities are added when action discovery becomes available later."""
mock_duco_client.async_get_node_actions.side_effect = [
@@ -304,13 +563,12 @@ async def test_select_entity_is_added_when_action_discovery_succeeds_later(
),
]
config_entry = await setup_platform_integration(
hass, mock_config_entry, [Platform.SELECT]
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SELECT])
assert hass.states.get(_SELECT_ENTITY) is None
await config_entry.runtime_data.async_refresh()
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(_SELECT_ENTITY)