diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index 139842d2db48..df7ea88e622f 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -167,6 +167,7 @@ async def async_setup_entry( @callback def _async_add_new_entities() -> None: + """Add new sensor entities and remove stale ones on coordinator updates.""" # Remove devices whose nodes have disappeared from the API. # The firmware removes deregistered RF/wired nodes automatically. # BSRH box sensors that are physically unplugged from the PCB are @@ -190,14 +191,19 @@ async def async_setup_entry( for node in coordinator.data.nodes.values(): if node.node_id in known_nodes: continue - known_nodes.add(node.node_id) if node.general.node_type == NodeType.UNKNOWN: - _LOGGER.warning( - "Duco node %s (%s) has an unsupported device type and will be ignored", + # Do not add the node to known_nodes so that it is re-evaluated + # on every coordinator update. This allows entities to be + # created automatically once a firmware update or library + # update adds support for the device type. + _LOGGER.debug( + "Duco node %s (%s) has an unsupported device type and will be " + "retried on subsequent coordinator updates", node.node_id, node.general.name, ) continue + known_nodes.add(node.node_id) new_entities.extend( DucoSensorEntity(coordinator, node, description) for description in SENSOR_DESCRIPTIONS diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index c39a2ceca1e2..eb0d3b342c56 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -1,5 +1,6 @@ """Tests for the Duco sensor platform.""" +import logging from unittest.mock import AsyncMock, patch from duco.exceptions import DucoConnectionError, DucoError @@ -255,3 +256,104 @@ async def test_unknown_node_type_logs_warning_and_creates_no_entities( identifiers={(DOMAIN, f"{mock_config_entry.unique_id}_99")} ) assert device is None + + +@pytest.mark.usefixtures("init_integration") +async def test_previously_unknown_node_gets_entities_after_type_becomes_known( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a node with UNKNOWN type is retried and gets entities once the type resolves.""" + node_id = 99 + ventilation = NodeVentilationInfo( + state="AUTO", time_state_remain=0, time_state_end=0, mode="-", flow_lvl_tgt=None + ) + sensor = NodeSensorInfo(co2=None, iaq_co2=None, rh=62.0, iaq_rh=70, temp=21.0) + + def _make_node(node_type: NodeType | str) -> Node: + """Create a Node with the given node type for use in tests.""" + return Node( + node_id=node_id, + general=NodeGeneralInfo( + node_type=node_type, + sub_type=0, + network_type="RF", + parent=1, + asso=1, + name="Future sensor", + identify=0, + ), + ventilation=ventilation, + sensor=sensor, + ) + + # First poll: UNKNOWN type — no entities created. + mock_duco_client.async_get_nodes.return_value = [ + *mock_nodes, + _make_node(NodeType.UNKNOWN), + ] + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert hass.states.get("sensor.future_sensor_humidity") is None + + # Second poll: type now resolved — entities must be created. + mock_duco_client.async_get_nodes.return_value = [*mock_nodes, _make_node("BSRH")] + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.future_sensor_humidity") + assert state is not None + assert state.state == "62.0" + + +@pytest.mark.usefixtures("init_integration") +async def test_unknown_node_logged_at_debug( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that UNKNOWN nodes are logged at DEBUG level on every coordinator update.""" + unknown_node = Node( + node_id=99, + general=NodeGeneralInfo( + node_type=NodeType.UNKNOWN, + sub_type=0, + network_type="RF", + parent=1, + asso=1, + name="Future sensor", + identify=0, + ), + ventilation=NodeVentilationInfo( + state="AUTO", + time_state_remain=0, + time_state_end=0, + mode="-", + flow_lvl_tgt=None, + ), + sensor=NodeSensorInfo(co2=None, iaq_co2=None, rh=None, iaq_rh=None, temp=None), + ) + mock_duco_client.async_get_nodes.return_value = [*mock_nodes, unknown_node] + + with caplog.at_level(logging.WARNING, logger="homeassistant.components.duco"): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "has an unsupported device type" not in caplog.text + + with caplog.at_level(logging.DEBUG, logger="homeassistant.components.duco"): + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "has an unsupported device type" in caplog.text