Add Wi-Fi signal strength diagnostic sensor to Duco (#168290)

This commit is contained in:
Ronald van der Meer
2026-04-17 15:48:30 +02:00
committed by GitHub
parent 3196bc6c44
commit e8a39e03b5
8 changed files with 206 additions and 15 deletions
+29 -2
View File
@@ -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,
)
+2 -1
View File
@@ -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,
+2 -2
View File
@@ -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]
+3 -3
View File
@@ -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
)
@@ -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
+63 -5
View File
@@ -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)
@@ -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': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.living_signal_strength',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Signal strength',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.SIGNAL_STRENGTH: 'signal_strength'>,
'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': <SensorStateClass.MEASUREMENT: 'measurement'>,
'unit_of_measurement': 'dBm',
}),
'context': <ANY>,
'entity_id': 'sensor.living_signal_strength',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '-60',
})
# ---
# name: test_sensor_entities_state[sensor.living_ventilation_state-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+51 -1
View File
@@ -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