From cc1e9dec3d5d315ec9bb86453a0b0d81db9db29b Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Tue, 8 Sep 2026 17:54:30 +0200 Subject: [PATCH] Add diagnostic subsystem sensors to Duco (#174577) --- .../components/duco/binary_sensor.py | 131 ++++++++ homeassistant/components/duco/const.py | 8 +- homeassistant/components/duco/coordinator.py | 29 ++ homeassistant/components/duco/icons.json | 14 + .../components/duco/quality_scale.yaml | 2 +- homeassistant/components/duco/strings.json | 14 + tests/components/duco/conftest.py | 4 + tests/components/duco/test_binary_sensor.py | 299 ++++++++++++++++++ tests/components/duco/test_sensor.py | 11 +- 9 files changed, 504 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/duco/binary_sensor.py create mode 100644 tests/components/duco/test_binary_sensor.py diff --git a/homeassistant/components/duco/binary_sensor.py b/homeassistant/components/duco/binary_sensor.py new file mode 100644 index 000000000000..adb7bed208b8 --- /dev/null +++ b/homeassistant/components/duco/binary_sensor.py @@ -0,0 +1,131 @@ +"""Binary sensor platform for the Duco integration.""" + +from typing import override + +from duco_connectivity.models import DiagStatus, Node + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import BOX_NODE_ID +from .coordinator import DucoConfigEntry, DucoCoordinator +from .entity import DucoEntity + +PARALLEL_UPDATES = 0 + +DIAGNOSTIC_STATUS_TO_PROBLEM = { + DiagStatus.DISABLED: True, + DiagStatus.ERROR: True, + DiagStatus.OK: False, +} + + +# Ventilation and filter problems are directly actionable. Model-specific +# subsystem diagnostics remain opt-in. +DIAGNOSTIC_BINARY_SENSOR_DESCRIPTIONS: dict[str, BinarySensorEntityDescription] = { + "Filter": BinarySensorEntityDescription( + key="filter", + translation_key="diagnostic_filter", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "SunCtrl": BinarySensorEntityDescription( + key="sun_control", + translation_key="diagnostic_sun_control", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "VentCool": BinarySensorEntityDescription( + key="ventilation_cooling", + translation_key="diagnostic_ventilation_cooling", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "Ventilation": BinarySensorEntityDescription( + key="ventilation", + translation_key="diagnostic_ventilation", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DucoConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Duco diagnostic binary sensors.""" + coordinator = entry.runtime_data + added_components: set[str] = set() + + @callback + def _async_add_new_entities() -> None: + """Add newly reported diagnostic subsystems.""" + if (box_node := coordinator.data.nodes.get(BOX_NODE_ID)) is None: + return + + new_entities: list[DucoDiagnosticBinarySensorEntity] = [] + for component in coordinator.data.diagnostic_subsystems: + if component in added_components: + continue + # Only expose components whose problem semantics are confirmed. + if ( + description := DIAGNOSTIC_BINARY_SENSOR_DESCRIPTIONS.get(component) + ) is None: + continue + added_components.add(component) + new_entities.append( + DucoDiagnosticBinarySensorEntity( + coordinator, box_node, component, description + ) + ) + + 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 DucoDiagnosticBinarySensorEntity(DucoEntity, BinarySensorEntity): + """Binary sensor for a Duco diagnostic subsystem.""" + + def __init__( + self, + coordinator: DucoCoordinator, + node: Node, + component: str, + description: BinarySensorEntityDescription, + ) -> None: + """Initialize the diagnostic binary sensor.""" + self.entity_description = description + self._component = component + super().__init__(coordinator, node) + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}" + ) + + @property + @override + def available(self) -> bool: + """Return whether current diagnostic data is available.""" + return super().available and self.coordinator.data.diagnostics_available + + @property + @override + def is_on(self) -> bool | None: + """Return whether the diagnostic subsystem reports a problem.""" + if ( + status := self.coordinator.data.diagnostic_subsystems.get(self._component) + ) is None: + return None + return DIAGNOSTIC_STATUS_TO_PROBLEM.get(status) diff --git a/homeassistant/components/duco/const.py b/homeassistant/components/duco/const.py index e62921a958a1..e00cb52b44e1 100644 --- a/homeassistant/components/duco/const.py +++ b/homeassistant/components/duco/const.py @@ -7,7 +7,13 @@ from duco_connectivity.models import NodeType from homeassistant.const import Platform DOMAIN = "duco" -PLATFORMS = [Platform.FAN, Platform.NUMBER, Platform.SELECT, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + 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 d164482ba855..706cfbe4a840 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -15,6 +15,7 @@ from duco_connectivity.exceptions import ( from duco_connectivity.models import ( BoardInfo, BypassSupplyTemperatureTarget, + DiagStatus, Node, NodeListActionItemList, NodeName, @@ -41,6 +42,8 @@ class DucoData: nodes: dict[int, Node] node_actions: NodeListActionItemList + diagnostics_available: bool + diagnostic_subsystems: dict[str, DiagStatus | None] rssi_wifi: int | None time_filter_remain: int | None ventilation_temperatures: VentilationTemperatureInfo | None @@ -227,6 +230,25 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): 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_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 @@ -261,9 +283,16 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): translation_key="api_error", ) from err + if diagnostics_available and not diagnostics_were_available: + _LOGGER.info("Duco diagnostics are available again") + elif not diagnostics_available and diagnostics_were_available: + _LOGGER.info("Duco diagnostics are unavailable: %s", diagnostics_error) + return DucoData( nodes={node.node_id: node for node in nodes}, node_actions=node_actions, + diagnostics_available=diagnostics_available, + diagnostic_subsystems=diagnostic_subsystems, rssi_wifi=rssi_wifi, time_filter_remain=time_filter_remain, ventilation_temperatures=ventilation_temperatures, diff --git a/homeassistant/components/duco/icons.json b/homeassistant/components/duco/icons.json index ecfb6cdc1cc9..d0377982eff4 100644 --- a/homeassistant/components/duco/icons.json +++ b/homeassistant/components/duco/icons.json @@ -1,5 +1,19 @@ { "entity": { + "binary_sensor": { + "diagnostic_filter": { + "default": "mdi:air-filter" + }, + "diagnostic_sun_control": { + "default": "mdi:blinds-horizontal-closed" + }, + "diagnostic_ventilation": { + "default": "mdi:fan-alert" + }, + "diagnostic_ventilation_cooling": { + "default": "mdi:home-thermometer-outline" + } + }, "sensor": { "filter_remaining": { "default": "mdi:air-filter" diff --git a/homeassistant/components/duco/quality_scale.yaml b/homeassistant/components/duco/quality_scale.yaml index 189d18372cd0..ebe2233ca354 100644 --- a/homeassistant/components/duco/quality_scale.yaml +++ b/homeassistant/components/duco/quality_scale.yaml @@ -42,7 +42,7 @@ rules: integration-owner: done log-when-unavailable: status: done - comment: Handled by the DataUpdateCoordinator. + comment: Handled by the coordinator, including isolated supplemental endpoints. parallel-updates: done reauthentication-flow: status: exempt diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index 0ae0f33bc4bd..f0da02e8cd5a 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -35,6 +35,20 @@ } }, "entity": { + "binary_sensor": { + "diagnostic_filter": { + "name": "Filter" + }, + "diagnostic_sun_control": { + "name": "Sun control" + }, + "diagnostic_ventilation": { + "name": "Ventilation" + }, + "diagnostic_ventilation_cooling": { + "name": "Ventilation cooling" + } + }, "fan": { "ventilation": { "state_attributes": { diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index 288c543fc69a..3f0b11df4e00 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -16,6 +16,7 @@ from duco_connectivity import ( ConfigNodeOverview, ConfigValueString, DiagComponent, + DiagInfo, KnownActionName, LanInfo, Node, @@ -327,6 +328,9 @@ def mock_duco_client( client.async_get_diagnostics.return_value = [ DiagComponent(component="Ventilation", status="Ok") ] + client.async_get_diagnostics_info.return_value = DiagInfo( + diagnostic_subsystems=(DiagComponent(component="Ventilation", status="Ok"),) + ) client.async_get_write_requests_remaining.return_value = 100 yield client diff --git a/tests/components/duco/test_binary_sensor.py b/tests/components/duco/test_binary_sensor.py new file mode 100644 index 000000000000..bc8bdfd18da8 --- /dev/null +++ b/tests/components/duco/test_binary_sensor.py @@ -0,0 +1,299 @@ +"""Tests for the Duco binary sensor platform.""" + +import logging +from unittest.mock import AsyncMock + +from duco_connectivity import ( + DiagComponent, + DiagInfo, + DucoConnectionError, + DucoError, + Node, +) +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.duco.const import BOX_NODE_ID, SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_platform_integration + +from tests.common import MockConfigEntry, async_fire_time_changed + +VENTILATION_PROBLEM_ENTITY_ID = "binary_sensor.living_ventilation" + +DIAGNOSTIC_ERROR_TYPES = [ + pytest.param(DucoConnectionError, id="connection_error"), + pytest.param(DucoError, id="duco_error"), +] + + +async def _async_refresh(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: + """Trigger a coordinator refresh.""" + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + +async def test_diagnostic_binary_sensor_entity_registry_defaults( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + entity_registry: er.EntityRegistry, +) -> None: + """Test the diagnostic binary sensor entity registry defaults.""" + mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo( + 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]) + + for entity_id, disabled_by in ( + ( + "binary_sensor.living_sun_control", + er.RegistryEntryDisabler.INTEGRATION, + ), + ( + "binary_sensor.living_ventilation_cooling", + er.RegistryEntryDisabler.INTEGRATION, + ), + ("binary_sensor.living_filter", None), + (VENTILATION_PROBLEM_ENTITY_ID, None), + ): + assert (entry := entity_registry.async_get(entity_id)) is not None + assert entry.disabled_by is disabled_by + assert entry.original_device_class is BinarySensorDeviceClass.PROBLEM + + +async def test_unknown_diagnostic_subsystem_is_ignored( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + 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"),) + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert entity_registry.async_get("binary_sensor.living_future_mode") is None + + +@pytest.mark.parametrize( + ("raw_status", "expected_state"), + [ + pytest.param("Ok", STATE_OFF, id="ok"), + pytest.param("Error", STATE_ON, id="error"), + pytest.param("Disable", STATE_ON, id="disabled"), + pytest.param("FutureState", STATE_UNKNOWN, id="unknown"), + ], +) +async def test_diagnostic_binary_sensor_problem_state( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + raw_status: str, + expected_state: str, +) -> None: + """Test diagnostic statuses map to the expected problem state.""" + mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo( + diagnostic_subsystems=( + DiagComponent(component="Ventilation", status=raw_status), + ) + ) + + await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + assert (state := hass.states.get(VENTILATION_PROBLEM_ENTITY_ID)) is not None + assert state.state == expected_state + assert state.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + +async def test_diagnostic_binary_sensors_added_after_initial_empty_response( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test diagnostic binary sensors can be added after an empty response.""" + mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo() + + 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"),) + ) + + await _async_refresh(hass, freezer) + + assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_ON) + + mock_duco_client.async_get_diagnostics_info.return_value = DiagInfo( + diagnostic_subsystems=( + DiagComponent(component="Ventilation", status="Error"), + DiagComponent(component="Filter", status="Ok"), + ) + ) + + await _async_refresh(hass, freezer) + + assert hass.states.is_state("binary_sensor.living_filter", STATE_OFF) + + +async def test_diagnostic_binary_sensors_wait_for_box_node( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_sensor_nodes: list[Node], + freezer: FrozenDateTimeFactory, +) -> None: + """Test diagnostic binary sensors are added when the box reappears.""" + mock_duco_client.async_get_nodes.return_value = [ + node for node in mock_sensor_nodes if node.node_id != BOX_NODE_ID + ] + + 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_nodes.return_value = mock_sensor_nodes + await _async_refresh(hass, freezer) + + assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) + + +@pytest.mark.parametrize( + "diagnostic_subsystems", + [ + pytest.param((), id="missing"), + pytest.param( + (DiagComponent(component="Ventilation", status="Unexpected"),), + id="unknown_status", + ), + ], +) +async def test_diagnostic_binary_sensor_becomes_unknown_without_known_status( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, + diagnostic_subsystems: tuple[DiagComponent, ...], +) -> None: + """Test diagnostic binary sensors report unknown without a known status.""" + await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + + 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 + ) + + await _async_refresh(hass, freezer) + + assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_UNKNOWN) + + +@pytest.mark.parametrize("exception_type", DIAGNOSTIC_ERROR_TYPES) +async def test_diagnostics_refresh_failure_is_isolated_and_recovers( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_sensor_nodes: list[Node], + freezer: FrozenDateTimeFactory, + exception_type: type[DucoError], +) -> None: + """Test a diagnostics refresh failure is isolated and recovers.""" + mock_duco_client.async_get_nodes.return_value = mock_sensor_nodes + await setup_platform_integration( + hass, mock_config_entry, [Platform.BINARY_SENSOR, Platform.SENSOR] + ) + + assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) + + mock_duco_client.async_get_diagnostics_info.side_effect = exception_type("error") + + await _async_refresh(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 + await _async_refresh(hass, freezer) + + assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) + + +@pytest.mark.parametrize("exception_type", DIAGNOSTIC_ERROR_TYPES) +async def test_initial_diagnostics_failure_is_isolated_and_recovers( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_sensor_nodes: list[Node], + freezer: FrozenDateTimeFactory, + exception_type: type[DucoError], +) -> 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") + + await setup_platform_integration( + hass, mock_config_entry, [Platform.BINARY_SENSOR, Platform.SENSOR] + ) + + assert mock_config_entry.state is ConfigEntryState.LOADED + 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 + await _async_refresh(hass, freezer) + + assert hass.states.is_state(VENTILATION_PROBLEM_ENTITY_ID, STATE_OFF) + + +async def test_diagnostics_availability_transitions_logged( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> 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") + + await setup_platform_integration(hass, mock_config_entry, [Platform.BINARY_SENSOR]) + await _async_refresh(hass, freezer) + + mock_duco_client.async_get_diagnostics_info.side_effect = None + await _async_refresh(hass, freezer) + await _async_refresh(hass, freezer) + + assert [ + record.message + for record in caplog.records + if record.name == "homeassistant.components.duco.coordinator" + ] == [ + "Duco diagnostics are unavailable: error", + "Duco diagnostics are available again", + ] diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index b7ce79b1fd5a..2973478af573 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -195,15 +195,14 @@ async def test_iaq_sensor_entities_disabled_by_default( @pytest.mark.usefixtures("init_integration") -async def test_diagnostic_sensor_entities_disabled_by_default( +async def test_rssi_sensor_disabled_by_default( hass: HomeAssistant, entity_registry: er.EntityRegistry, ) -> None: - """Test that diagnostic sensor entities are disabled by default.""" - for entity_id in ("sensor.living_signal_strength",): - entry = entity_registry.async_get(entity_id) - assert entry is not None - assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION + """Test that the RSSI sensor is disabled by default.""" + entry = entity_registry.async_get("sensor.living_signal_strength") + assert entry is not None + assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION @pytest.mark.usefixtures("init_integration")