Add diagnostic subsystem sensors to Duco (#174577)

This commit is contained in:
Ronald van der Meer
2026-09-08 17:54:30 +02:00
committed by GitHub
parent 48155c888f
commit cc1e9dec3d
9 changed files with 504 additions and 8 deletions
@@ -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)
+7 -1
View File
@@ -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, ...] = (
@@ -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,
+14
View File
@@ -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"
@@ -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
@@ -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": {
+4
View File
@@ -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
+299
View File
@@ -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",
]
+5 -6
View File
@@ -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")