Refactor Duco polling to use the info overview (#182087)

This commit is contained in:
Ronald van der Meer
2026-09-24 15:10:33 +01:00
committed by GitHub
parent 73b12d807d
commit 6f36091610
6 changed files with 118 additions and 92 deletions
+16 -36
View File
@@ -1,7 +1,6 @@
"""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
@@ -73,6 +72,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
config_entry=config_entry,
name=DOMAIN,
update_interval=SCAN_INTERVAL,
always_update=False,
)
self.client = client
self._configured_node_names = {}
@@ -219,53 +219,33 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
exc_info=err,
)
# LAN info only backs the diagnostic RSSI sensor, so failures on this
# supplemental endpoint, including connection failures, should not make
# the primary node entities unavailable.
# The overview only backs supplemental entities, so failures preserve
# known values where possible without making primary entities unavailable.
rssi_wifi = self.data.rssi_wifi if self.data else None
try:
lan_info = await self.client.async_get_lan_info()
except DucoError as err:
_LOGGER.debug("Could not fetch Duco LAN info", exc_info=err)
else:
rssi_wifi = lan_info.rssi_wifi
# Diagnostics only back optional binary sensors. Preserve known components
# but mark their data unavailable without failing the shared coordinator.
diagnostics_were_available = (
self.data is None or self.data.diagnostics_available
)
diagnostics_available = True
diagnostics_available = False
diagnostics_error: DucoError | None = None
diagnostic_subsystems = self.data.diagnostic_subsystems if self.data else {}
try:
diagnostic_info = await self.client.async_get_diagnostics_info()
except DucoError as err:
diagnostics_available = False
diagnostics_error = err
else:
diagnostic_subsystems = {
diagnostic.component: diagnostic.status
for diagnostic in diagnostic_info.diagnostic_subsystems
}
# Heat recovery info only backs the optional filter timer sensor, so
# failures on this supplemental endpoint should not make the primary
# node entities unavailable. A None result leaves the sensor absent
# but keeps the helper pollable so data can appear on a later refresh.
time_filter_remain = None
with suppress(DucoError):
time_filter_remain = await self.client.async_get_time_filter_remaining()
ventilation_temperatures = (
self.data.ventilation_temperatures if self.data else None
)
try:
ventilation_temperatures = (
await self.client.async_get_ventilation_temperature_info()
)
info_overview = await self.client.async_get_info_overview()
except DucoError as err:
_LOGGER.debug("Could not fetch Duco ventilation temperatures", exc_info=err)
diagnostics_error = err
_LOGGER.debug("Could not fetch Duco info overview", exc_info=err)
else:
rssi_wifi = info_overview.rssi_wifi
diagnostics_available = True
diagnostic_subsystems = {
diagnostic.component: diagnostic.status
for diagnostic in info_overview.diagnostic_subsystems
}
time_filter_remain = info_overview.time_filter_remain
ventilation_temperatures = info_overview.ventilation_temperatures
bypass_supply_temperature_targets: dict[int, BypassSupplyTemperatureTarget] = {}
try:
+1 -1
View File
@@ -285,7 +285,7 @@ async def async_setup_entry(
if node.node_id not in known_nodes:
if node.general.node_type == NodeType.UNKNOWN:
# Do not add the node to known_nodes so that it is re-evaluated
# on every coordinator update. This allows entities to be
# when coordinator data changes. This allows entities to be
# created automatically once a firmware update or library
# update adds support for the device type.
_LOGGER.debug(
+9
View File
@@ -17,6 +17,7 @@ from duco_connectivity import (
ConfigValueString,
DiagComponent,
DiagInfo,
InfoOverview,
KnownActionName,
LanInfo,
Node,
@@ -315,6 +316,14 @@ def mock_duco_client(
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_info_overview.return_value = InfoOverview(
rssi_wifi=mock_lan_info.rssi_wifi,
diagnostic_subsystems=(
DiagComponent(component="Ventilation", status="Ok"),
),
time_filter_remain=180,
ventilation_temperatures=mock_ventilation_temperature_info,
)
client.async_get_time_filter_remaining.return_value = 180
client.async_get_ventilation_temperature_info.return_value = (
mock_ventilation_temperature_info
+30 -26
View File
@@ -1,15 +1,10 @@
"""Tests for the Duco binary sensor platform."""
from dataclasses import replace
import logging
from unittest.mock import AsyncMock
from duco_connectivity import (
DiagComponent,
DiagInfo,
DucoConnectionError,
DucoError,
Node,
)
from duco_connectivity import DiagComponent, DucoConnectionError, DucoError, Node
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -45,13 +40,14 @@ async def test_diagnostic_binary_sensor_entity_registry_defaults(
entity_registry: er.EntityRegistry,
) -> None:
"""Test the diagnostic binary sensor entity registry defaults."""
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo(
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=(
DiagComponent(component="Ventilation", status="Ok"),
DiagComponent(component="Filter", status="Ok"),
DiagComponent(component="VentCool", status="Ok"),
DiagComponent(component="SunCtrl", status="Ok"),
)
),
)
await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])
@@ -80,8 +76,9 @@ async def test_unknown_diagnostic_subsystem_is_ignored(
entity_registry: er.EntityRegistry,
) -> None:
"""Test an unknown diagnostic subsystem is not exposed."""
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo(
diagnostic_subsystems=(DiagComponent(component="Future Mode", status="Error"),)
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=(DiagComponent(component="Future Mode", status="Error"),),
)
await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])
@@ -106,10 +103,11 @@ async def test_diagnostic_binary_sensor_problem_state(
expected_state: str,
) -> None:
"""Test diagnostic statuses map to the expected problem state."""
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo(
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=(
DiagComponent(component="Ventilation", status=raw_status),
)
),
)
await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])
@@ -126,26 +124,31 @@ async def test_diagnostic_binary_sensors_added_after_initial_empty_response(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test diagnostic binary sensors can be added after an empty response."""
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo()
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=(),
)
await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])
assert mock_config_entry.state is ConfigEntryState.LOADED
assert hass.states.get(VENTILATION_PROBLEM_ENTITY_ID) is None
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo(
diagnostic_subsystems=(DiagComponent(component="Ventilation", status="Error"),)
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=(DiagComponent(component="Ventilation", status="Error"),),
)
await async_fire_coordinator_update(hass, freezer)
assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_ON)
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo(
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=(
DiagComponent(component="Ventilation", status="Error"),
DiagComponent(component="Filter", status="Ok"),
)
),
)
await async_fire_coordinator_update(hass, freezer)
@@ -198,8 +201,9 @@ async def test_diagnostic_binary_sensor_becomes_unknown_without_known_status(
assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF)
mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo(
diagnostic_subsystems=diagnostic_subsystems
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
diagnostic_subsystems=diagnostic_subsystems,
)
await async_fire_coordinator_update(hass, freezer)
@@ -224,14 +228,14 @@ async def test_diagnostics_refresh_failure_is_isolated_and_recovers(
assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF)
mock_duco_client.async_get_diagnostics_info.side_effect = exception_type("error")
mock_duco_client.async_get_info_overview.side_effect = exception_type("error")
await async_fire_coordinator_update(hass, freezer)
assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_UNAVAILABLE)
assert hass.states.is_state("sensor.office_co2_carbon_dioxide", "405")
mock_duco_client.async_get_diagnostics_info.side_effect = None
mock_duco_client.async_get_info_overview.side_effect = None
await async_fire_coordinator_update(hass, freezer)
assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF)
@@ -248,7 +252,7 @@ async def test_initial_diagnostics_failure_is_isolated_and_recovers(
) -> None:
"""Test an initial diagnostics failure is isolated and recovers."""
mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes
mock_duco_client.async_get_diagnostics_info.side_effect = exception_type("error")
mock_duco_client.async_get_info_overview.side_effect = exception_type("error")
await setup_platform_integration(
hass, mock_config_entry, [Platform.BINARY_SENSOR, Platform.SENSOR]
@@ -258,7 +262,7 @@ async def test_initial_diagnostics_failure_is_isolated_and_recovers(
assert hass.states.get(VENTILATION_PROBLEM_ENTITY_ID) is None
assert hass.states.is_state("sensor.office_co2_carbon_dioxide", "405")
mock_duco_client.async_get_diagnostics_info.side_effect = None
mock_duco_client.async_get_info_overview.side_effect = None
await async_fire_coordinator_update(hass, freezer)
assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF)
@@ -273,12 +277,12 @@ async def test_diagnostics_availability_transitions_logged(
) -> None:
"""Test diagnostics availability transitions are logged once."""
caplog.set_level(logging.INFO, logger="homeassistant.components.duco.coordinator")
mock_duco_client.async_get_diagnostics_info.side_effect = DucoError("error")
mock_duco_client.async_get_info_overview.side_effect = DucoError("error")
await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR])
await async_fire_coordinator_update(hass, freezer)
mock_duco_client.async_get_diagnostics_info.side_effect = None
mock_duco_client.async_get_info_overview.side_effect = None
await async_fire_coordinator_update(hass, freezer)
await async_fire_coordinator_update(hass, freezer)
+20 -5
View File
@@ -1,5 +1,6 @@
"""Tests for the Duco integration setup."""
from dataclasses import replace
from datetime import timedelta
from unittest.mock import ANY, AsyncMock, patch
@@ -13,6 +14,7 @@ from duco_connectivity import (
DucoConnectionError,
DucoError,
DucoResponseError,
InfoOverview,
LanInfo,
Node,
NodeListActionItemList,
@@ -138,9 +140,15 @@ async def test_setup_entry_error(
async def test_setup_entry_success(
hass: HomeAssistant,
init_integration: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test successful setup of the Duco integration."""
assert init_integration.state is ConfigEntryState.LOADED
mock_duco_client.async_get_info_overview.assert_awaited_once_with()
mock_duco_client.async_get_lan_info.assert_not_awaited()
mock_duco_client.async_get_diagnostics_info.assert_not_awaited()
mock_duco_client.async_get_time_filter_remaining.assert_not_awaited()
mock_duco_client.async_get_ventilation_temperature_info.assert_not_awaited()
async def test_device_via_device_links(
@@ -180,14 +188,14 @@ async def test_device_via_device_links(
pytest.param(DucoConnectionError("lan info offline"), id="connection_error"),
],
)
async def test_setup_entry_ignores_lan_info_failures(
async def test_setup_entry_ignores_info_overview_failures(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
exception: Exception,
) -> None:
"""Test setup succeeds when the supplemental LAN info endpoint fails."""
mock_duco_client.async_get_lan_info.side_effect = exception
"""Test setup succeeds when the supplemental info overview fails."""
mock_duco_client.async_get_info_overview.side_effect = exception
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
@@ -211,9 +219,12 @@ async def test_setup_entry_recovers_from_optional_temperature_capability_failure
exception: Exception,
) -> None:
"""Test an optional temperature capability is retried after a setup failure."""
mock_duco_client.async_get_ventilation_temperature_info.side_effect = [
mock_duco_client.async_get_info_overview.side_effect = [
exception,
VentilationTemperatureInfo(temp_oda=5.5),
replace(
mock_duco_client.async_get_info_overview.return_value,
ventilation_temperatures=VentilationTemperatureInfo(temp_oda=5.5),
),
]
mock_config_entry.add_to_hass(hass)
@@ -443,6 +454,9 @@ async def test_setup_entry_creates_http_client(
mock_client_class.return_value.async_get_node_actions.return_value = (
mock_node_actions
)
mock_client_class.return_value.async_get_info_overview.return_value = (
InfoOverview()
)
(
mock_client_class.return_value.async_get_ventilation_temperature_info.return_value
) = VentilationTemperatureInfo()
@@ -470,6 +484,7 @@ async def test_setup_entry_creates_http_client(
session=ANY,
host=TEST_HOST,
)
mock_client_class.return_value.async_get_info_overview.assert_awaited_once_with()
async def test_setup_entry_uses_configured_node_name(
+42 -24
View File
@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock
from duco_connectivity import (
DucoConnectionError,
DucoError,
InfoOverview,
Node,
NodeGeneralInfo,
NodeSensorInfo,
@@ -239,14 +240,14 @@ async def test_coordinator_update_failure_marks_unavailable(
],
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration")
async def test_lan_info_failures_keep_node_entities_available(
async def test_info_overview_failures_keep_node_entities_available(
hass: HomeAssistant,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test node entities stay available when LAN info retrieval fails."""
mock_duco_client.async_get_lan_info = AsyncMock(side_effect=exception)
"""Test node entities stay available when info overview retrieval fails."""
mock_duco_client.async_get_info_overview.side_effect = exception
await async_fire_coordinator_update(hass, freezer)
@@ -260,9 +261,9 @@ async def test_lan_info_failures_keep_node_entities_available(
@pytest.mark.parametrize(
"initial_time_filter_remain",
"initial_info_overview_result",
[
pytest.param(None, id="missing"),
pytest.param(InfoOverview(time_filter_remain=None), id="missing"),
pytest.param(DucoError("heat recovery info error"), id="transient_failure"),
],
)
@@ -272,13 +273,14 @@ async def test_time_filter_remaining_is_retried(
mock_duco_client: AsyncMock,
mock_sensor_nodes: list[Node],
freezer: FrozenDateTimeFactory,
initial_time_filter_remain: DucoError | None,
initial_info_overview_result: InfoOverview | DucoError,
) -> None:
"""Test unavailable filter timer data is retried and can create the sensor."""
mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes
mock_duco_client.async_get_time_filter_remaining.side_effect = [
initial_time_filter_remain,
180,
info_overview = mock_duco_client.async_get_info_overview.return_value
mock_duco_client.async_get_info_overview.side_effect = [
initial_info_overview_result,
info_overview,
]
await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
@@ -287,7 +289,7 @@ async def test_time_filter_remaining_is_retried(
await async_fire_coordinator_update(hass, freezer)
assert mock_duco_client.async_get_time_filter_remaining.await_count == 2
assert mock_duco_client.async_get_info_overview.await_count == 2
state = hass.states.get(FILTER_REMAINING_ENTITY_ID)
assert state is not None
assert state.state == "180"
@@ -300,9 +302,13 @@ async def test_empty_ventilation_temperatures_are_retried(
freezer: FrozenDateTimeFactory,
) -> None:
"""Test empty ventilation temperatures are retried and can appear later."""
mock_duco_client.async_get_ventilation_temperature_info.side_effect = [
VentilationTemperatureInfo(),
VentilationTemperatureInfo(temp_oda=5.5),
info_overview = mock_duco_client.async_get_info_overview.return_value
mock_duco_client.async_get_info_overview.side_effect = [
replace(info_overview, ventilation_temperatures=VentilationTemperatureInfo()),
replace(
info_overview,
ventilation_temperatures=VentilationTemperatureInfo(temp_oda=5.5),
),
]
await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
@@ -312,7 +318,7 @@ async def test_empty_ventilation_temperatures_are_retried(
await async_fire_coordinator_update(hass, freezer)
assert mock_duco_client.async_get_ventilation_temperature_info.await_count == 2
assert mock_duco_client.async_get_info_overview.await_count == 2
state = hass.states.get("sensor.living_outdoor_air_temperature")
assert state is not None
assert state.state == "5.5"
@@ -324,8 +330,11 @@ async def test_partial_ventilation_temperatures_only_expose_available_sensor_val
mock_duco_client: AsyncMock,
) -> None:
"""Test only populated ventilation temperature fields are exposed as states."""
mock_duco_client.async_get_ventilation_temperature_info.return_value = (
VentilationTemperatureInfo(temp_oda=5.5, temp_eta=21.4)
mock_duco_client.async_get_info_overview.return_value = replace(
mock_duco_client.async_get_info_overview.return_value,
ventilation_temperatures=VentilationTemperatureInfo(
temp_oda=5.5, temp_eta=21.4
),
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
@@ -571,7 +580,7 @@ async def test_previously_unknown_node_gets_entities_after_type_becomes_known(
@pytest.mark.usefixtures("init_integration")
async def test_unknown_node_logged_at_debug(
async def test_unknown_node_logged_at_debug_when_data_changes(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
@@ -579,7 +588,7 @@ async def test_unknown_node_logged_at_debug(
freezer: FrozenDateTimeFactory,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that UNKNOWN nodes are logged at DEBUG level on every coordinator update."""
"""Test that UNKNOWN nodes are logged at DEBUG level when data changes."""
unknown_node = Node(
node_id=99,
general=NodeGeneralInfo(
@@ -602,15 +611,24 @@ async def test_unknown_node_logged_at_debug(
)
mock_duco_client.async_get_nodes.return_value = [*mock_sensor_nodes, unknown_node]
with caplog.at_level(logging.WARNING, logger="homeassistant.components.duco"):
await async_fire_coordinator_update(hass, freezer)
assert "has an unsupported device type" not in caplog.text
with caplog.at_level(logging.DEBUG, logger="homeassistant.components.duco"):
await async_fire_coordinator_update(hass, freezer)
assert "has an unsupported device type" in caplog.text
assert "has an unsupported device type" in caplog.text
caplog.clear()
await async_fire_coordinator_update(hass, freezer)
assert "has an unsupported device type" not in caplog.text
caplog.clear()
mock_duco_client.async_get_nodes.return_value = [
*mock_sensor_nodes,
replace(unknown_node, general=replace(unknown_node.general, identify=1)),
]
await async_fire_coordinator_update(hass, freezer)
assert "has an unsupported device type" in caplog.text
@pytest.mark.usefixtures("init_integration")