From e8a39e03b58c5f453b6728c09f0292fd99de809e Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Fri, 17 Apr 2026 15:48:30 +0200 Subject: [PATCH] Add Wi-Fi signal strength diagnostic sensor to Duco (#168290) --- homeassistant/components/duco/coordinator.py | 31 ++++++++- homeassistant/components/duco/diagnostics.py | 3 +- homeassistant/components/duco/entity.py | 4 +- homeassistant/components/duco/fan.py | 6 +- .../components/duco/quality_scale.yaml | 2 +- homeassistant/components/duco/sensor.py | 68 +++++++++++++++++-- .../duco/snapshots/test_sensor.ambr | 55 +++++++++++++++ tests/components/duco/test_sensor.py | 52 +++++++++++++- 8 files changed, 206 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index c18af9fdffd7..531d843f39e2 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass import logging from duco import DucoClient @@ -18,7 +19,14 @@ from .const import DOMAIN, SCAN_INTERVAL _LOGGER = logging.getLogger(__name__) type DucoConfigEntry = ConfigEntry[DucoCoordinator] -type DucoData = dict[int, Node] + + +@dataclass +class DucoData: + """Data returned by the Duco coordinator.""" + + nodes: dict[int, Node] + rssi_wifi: int | None class DucoCoordinator(DataUpdateCoordinator[DucoData]): @@ -72,4 +80,23 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]): translation_key="api_error", translation_placeholders={"error": repr(err)}, ) from err - return {node.node_id: node for node in nodes} + + try: + lan_info = await self.client.async_get_lan_info() + except DucoConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except DucoError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error": repr(err)}, + ) from err + + return DucoData( + nodes={node.node_id: node for node in nodes}, + rssi_wifi=lan_info.rssi_wifi, + ) diff --git a/homeassistant/components/duco/diagnostics.py b/homeassistant/components/duco/diagnostics.py index ec989b60006f..78a23db7a9cf 100644 --- a/homeassistant/components/duco/diagnostics.py +++ b/homeassistant/components/duco/diagnostics.py @@ -44,7 +44,8 @@ async def async_get_config_entry_diagnostics( "board_info": board, "lan_info": asdict(lan_info), "nodes": { - str(node_id): asdict(node) for node_id, node in coordinator.data.items() + str(node_id): asdict(node) + for node_id, node in coordinator.data.nodes.items() }, "duco_diagnostics": [asdict(d) for d in duco_diags], "write_requests_remaining": write_remaining, diff --git a/homeassistant/components/duco/entity.py b/homeassistant/components/duco/entity.py index bed8b6bb570a..5300e1e072d0 100644 --- a/homeassistant/components/duco/entity.py +++ b/homeassistant/components/duco/entity.py @@ -44,9 +44,9 @@ class DucoEntity(CoordinatorEntity[DucoCoordinator]): @property def available(self) -> bool: """Return True if entity is available.""" - return super().available and self._node_id in self.coordinator.data + return super().available and self._node_id in self.coordinator.data.nodes @property def _node(self) -> Node: """Return the current node data from the coordinator.""" - return self.coordinator.data[self._node_id] + return self.coordinator.data.nodes[self._node_id] diff --git a/homeassistant/components/duco/fan.py b/homeassistant/components/duco/fan.py index 0f967277b17d..b03fb58405d5 100644 --- a/homeassistant/components/duco/fan.py +++ b/homeassistant/components/duco/fan.py @@ -3,7 +3,7 @@ from __future__ import annotations from duco.exceptions import DucoError -from duco.models import Node, VentilationState +from duco.models import Node, NodeType, VentilationState from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant @@ -62,8 +62,8 @@ async def async_setup_entry( async_add_entities( DucoVentilationFanEntity(coordinator, node) - for node in coordinator.data.values() - if node.general.node_type == "BOX" + for node in coordinator.data.nodes.values() + if node.general.node_type == NodeType.BOX ) diff --git a/homeassistant/components/duco/quality_scale.yaml b/homeassistant/components/duco/quality_scale.yaml index ee2e54580094..4308d7535953 100644 --- a/homeassistant/components/duco/quality_scale.yaml +++ b/homeassistant/components/duco/quality_scale.yaml @@ -70,7 +70,7 @@ rules: 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. - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: done entity-translations: done diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index c8e269e7e459..529ce2a0fab6 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -13,7 +13,12 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -31,6 +36,13 @@ class DucoSensorEntityDescription(SensorEntityDescription): node_types: tuple[NodeType, ...] +@dataclass(frozen=True, kw_only=True) +class DucoBoxSensorEntityDescription(SensorEntityDescription): + """Duco sensor entity description for box-level diagnostic data.""" + + value_fn: Callable[[DucoCoordinator], int | float | None] + + SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = ( DucoSensorEntityDescription( key="ventilation_state", @@ -78,6 +90,18 @@ SENSOR_DESCRIPTIONS: tuple[DucoSensorEntityDescription, ...] = ( ), ) +BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = ( + DucoBoxSensorEntityDescription( + key="rssi_wifi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda coordinator: coordinator.data.rssi_wifi, + ), +) + async def async_setup_entry( hass: HomeAssistant, @@ -88,10 +112,20 @@ async def async_setup_entry( coordinator = entry.runtime_data async_add_entities( - DucoSensorEntity(coordinator, node, description) - for node in coordinator.data.values() - for description in SENSOR_DESCRIPTIONS - if node.general.node_type in description.node_types + [ + *[ + 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 + ], + *[ + DucoBoxSensorEntity(coordinator, node, description) + for node in coordinator.data.nodes.values() + for description in BOX_SENSOR_DESCRIPTIONS + if node.general.node_type == NodeType.BOX + ], + ] ) @@ -117,3 +151,27 @@ class DucoSensorEntity(DucoEntity, SensorEntity): def native_value(self) -> int | float | str | None: """Return the sensor value.""" return self.entity_description.value_fn(self._node) + + +class DucoBoxSensorEntity(DucoEntity, SensorEntity): + """Sensor entity for box-level diagnostic data.""" + + entity_description: DucoBoxSensorEntityDescription + + def __init__( + self, + coordinator: DucoCoordinator, + node: Node, + description: DucoBoxSensorEntityDescription, + ) -> None: + """Initialize the box sensor entity.""" + super().__init__(coordinator, node) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.unique_id}_{node.node_id}_{description.key}" + ) + + @property + def native_value(self) -> int | float | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator) diff --git a/tests/components/duco/snapshots/test_sensor.ambr b/tests/components/duco/snapshots/test_sensor.ambr index 6afa88369b70..74b03a30236c 100644 --- a/tests/components/duco/snapshots/test_sensor.ambr +++ b/tests/components/duco/snapshots/test_sensor.ambr @@ -108,6 +108,61 @@ 'state': '85', }) # --- +# name: test_sensor_entities_state[sensor.living_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.living_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal strength', + 'platform': 'duco', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'aa:bb:cc:dd:ee:ff_1_rssi_wifi', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_sensor_entities_state[sensor.living_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Living Signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.living_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-60', + }) +# --- # name: test_sensor_entities_state[sensor.living_ventilation_state-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 004c63e02d35..2a6ef1800f97 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -from duco.exceptions import DucoConnectionError +from duco.exceptions import DucoConnectionError, DucoError from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion @@ -57,6 +57,18 @@ async def test_iaq_sensor_entities_disabled_by_default( assert entry.disabled_by == er.RegistryEntryDisabler.INTEGRATION +@pytest.mark.usefixtures("init_integration") +async def test_diagnostic_sensor_entities_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 + + @pytest.mark.usefixtures("init_integration") async def test_coordinator_update_marks_unavailable( hass: HomeAssistant, @@ -75,3 +87,41 @@ async def test_coordinator_update_marks_unavailable( state = hass.states.get("sensor.office_co2_carbon_dioxide") assert state is not None assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("init_integration") +async def test_coordinator_update_duco_error_marks_unavailable( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that sensor entities become unavailable when async_get_nodes raises DucoError.""" + mock_duco_client.async_get_nodes = AsyncMock(side_effect=DucoError("api error")) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.office_co2_carbon_dioxide") + assert state is not None + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_lan_info_duco_error_marks_unavailable( + hass: HomeAssistant, + mock_duco_client: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that entities become unavailable when async_get_lan_info raises DucoError.""" + mock_duco_client.async_get_lan_info = AsyncMock( + side_effect=DucoError("lan info error") + ) + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("sensor.living_signal_strength") + assert state is not None + assert state.state == STATE_UNAVAILABLE