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:
committed by
GitHub
parent
a2485960d8
commit
8e1346fd1f
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user