1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-24 17:30:08 +01:00

Add dynamic device discovery and stale device removal to Duco integration (#168675)

This commit is contained in:
Ronald van der Meer
2026-04-21 15:18:27 +02:00
committed by GitHub
parent a2485960d8
commit 8e1346fd1f
4 changed files with 128 additions and 23 deletions
+1
View File
@@ -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()
@@ -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
+43 -11
View File
@@ -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):
+82 -2
View File
@@ -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