diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 706cfbe4a840..d458fe274c24 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -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: diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index 5899f7d677fd..221fb2d0469a 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -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( diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 3f0b11df4e00..162504d49e4c 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -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 diff --git a/tests/components/duco/test_binary_sensor.py b/tests/components/duco/test_binary_sensor.py index bc3aa92b39eb..7f39306dea29 100644 --- a/tests/components/duco/test_binary_sensor.py +++ b/tests/components/duco/test_binary_sensor.py @@ -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) diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 20c9b68a8467..025d366be08e 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -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( diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index b342e93a2ffd..f5d1125a6bb1 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -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")