diff --git a/homeassistant/components/duco/fan.py b/homeassistant/components/duco/fan.py index 64db6c522c9..77adbe432e2 100644 --- a/homeassistant/components/duco/fan.py +++ b/homeassistant/components/duco/fan.py @@ -64,6 +64,7 @@ async def async_setup_entry( """Set up Duco fan entities.""" coordinator = entry.runtime_data + # BOX is always node 1 and is never dynamically added or removed, so no listener needed. async_add_entities( DucoVentilationFanEntity(coordinator, node) for node in coordinator.data.nodes.values() diff --git a/homeassistant/components/duco/quality_scale.yaml b/homeassistant/components/duco/quality_scale.yaml index 44aba8b18d2..598e5529854 100644 --- a/homeassistant/components/duco/quality_scale.yaml +++ b/homeassistant/components/duco/quality_scale.yaml @@ -55,11 +55,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: - status: todo - comment: >- - Users can pair new modules (CO2 sensors, humidity sensors, zone valves) - to their Duco box. Dynamic device support to be added in a follow-up PR. + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: done @@ -74,11 +70,7 @@ rules: handled by the coordinator (unavailable entities) and resolve automatically. There are no credentials to expire and no versioned API to become incompatible with. - stale-devices: - status: todo - comment: >- - To be implemented together with dynamic device support in a follow-up PR. - + stale-devices: done # Platinum async-dependency: done inject-websession: done diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index 529ce2a0fab..411e5ebaf8e 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -19,9 +19,11 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import DOMAIN from .coordinator import DucoConfigEntry, DucoCoordinator from .entity import DucoEntity @@ -111,22 +113,52 @@ async def async_setup_entry( """Set up Duco sensor entities.""" coordinator = entry.runtime_data - async_add_entities( - [ - *[ + # Track the node IDs for which entities have already been created, so we + # can detect both newly added and stale (deregistered) nodes on every + # coordinator update. + known_nodes: set[int] = set() + + @callback + def _async_add_new_entities() -> None: + # 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 + # not deregistered by the firmware and will never appear here as stale. + stale_node_ids = known_nodes - coordinator.data.nodes.keys() + if stale_node_ids: + device_reg = dr.async_get(hass) + mac = entry.unique_id + for node_id in stale_node_ids: + device = device_reg.async_get_device( + identifiers={(DOMAIN, f"{mac}_{node_id}")} + ) + if device: + device_reg.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + known_nodes.difference_update(stale_node_ids) + + new_entities: list[SensorEntity] = [] + for node in coordinator.data.nodes.values(): + if node.node_id in known_nodes: + continue + known_nodes.add(node.node_id) + new_entities.extend( DucoSensorEntity(coordinator, node, description) - for node in coordinator.data.nodes.values() for description in SENSOR_DESCRIPTIONS if node.general.node_type in description.node_types - ], - *[ + ) + new_entities.extend( DucoBoxSensorEntity(coordinator, node, description) - for node in coordinator.data.nodes.values() for description in BOX_SENSOR_DESCRIPTIONS if node.general.node_type == NodeType.BOX - ], - ] - ) + ) + 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 DucoSensorEntity(DucoEntity, SensorEntity): diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 2a6ef1800f9..dd6c794e5a6 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -5,14 +5,15 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch from duco.exceptions import DucoConnectionError, DucoError +from duco.models import Node, NodeGeneralInfo, NodeSensorInfo, NodeVentilationInfo from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.duco.const import SCAN_INTERVAL +from homeassistant.components.duco.const import DOMAIN, SCAN_INTERVAL from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @@ -125,3 +126,82 @@ async def test_lan_info_duco_error_marks_unavailable( state = hass.states.get("sensor.living_signal_strength") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_new_node_added_dynamically( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a new node appearing in coordinator data creates entities automatically.""" + assert hass.states.get("sensor.new_rh_sensor_humidity") is None + + new_node = Node( + node_id=200, + general=NodeGeneralInfo( + node_type="BSRH", + sub_type=0, + network_type="RF", + parent=1, + asso=1, + name="New RH 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=55.0, + iaq_rh=70, + ), + ) + mock_duco_client.async_get_nodes.return_value = [*mock_nodes, new_node] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.new_rh_sensor_humidity") + assert state is not None + assert state.state == "55.0" + + +@pytest.mark.usefixtures("init_integration") +async def test_deregistered_node_removes_device( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + mock_nodes: list[Node], + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that a node disappearing from the API removes its device from the registry.""" + device_registry = dr.async_get(hass) + + # Verify node 2 (UCCO2 RF sensor) device exists before deregistration. + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mock_config_entry.unique_id}_2")} + ) + assert device is not None + + # Simulate the firmware removing the deregistered node from the API response. + mock_duco_client.async_get_nodes.return_value = [ + node for node in mock_nodes if node.node_id != 2 + ] + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + # The device should be removed from the device registry. + device = device_registry.async_get_device( + identifiers={(DOMAIN, f"{mock_config_entry.unique_id}_2")} + ) + assert device is None