Add Duco ventilation temperature sensors (#176621)

This commit is contained in:
Ronald van der Meer
2026-07-18 09:37:39 +02:00
committed by GitHub
parent 514dbb953e
commit 791263ada5
7 changed files with 452 additions and 2 deletions
+29 -2
View File
@@ -10,8 +10,15 @@ from duco_connectivity.exceptions import (
DucoConnectionError,
DucoError,
DucoResponseError,
DucoUnsupportedCapabilityError,
)
from duco_connectivity.models import (
BoardInfo,
Node,
NodeListActionItemList,
NodeName,
VentilationTemperatureInfo,
)
from duco_connectivity.models import BoardInfo, Node, NodeListActionItemList, NodeName
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -26,7 +33,7 @@ _LOGGER = logging.getLogger(__name__)
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
@dataclass
@dataclass(slots=True, kw_only=True)
class DucoData:
"""Data returned by the Duco coordinator."""
@@ -34,6 +41,7 @@ class DucoData:
node_actions: NodeListActionItemList
rssi_wifi: int | None
time_filter_remain: int | None
ventilation_temperatures: VentilationTemperatureInfo | None
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
@@ -42,6 +50,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
config_entry: DucoConfigEntry
board_info: BoardInfo
_supports_time_filter_remain: bool
_supports_ventilation_temperatures: bool
_configured_node_names: dict[int, str]
def __init__(
@@ -61,6 +70,7 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
self.client = client
self._configured_node_names = {}
self._supports_time_filter_remain = True
self._supports_ventilation_temperatures = True
async def _async_load_node_names(self) -> None:
"""Load configured Duco node names during setup."""
@@ -175,9 +185,26 @@ class DucoCoordinator(DataUpdateCoordinator[DucoData]):
time_filter_remain = await self.client.async_get_time_filter_remaining()
self._supports_time_filter_remain = time_filter_remain is not None
ventilation_temperatures = (
self.data.ventilation_temperatures if self.data else None
)
if self._supports_ventilation_temperatures:
try:
ventilation_temperatures = (
await self.client.async_get_ventilation_temperature_info()
)
except DucoUnsupportedCapabilityError:
ventilation_temperatures = None
self._supports_ventilation_temperatures = False
except DucoError as err:
_LOGGER.debug(
"Could not fetch Duco ventilation temperatures", exc_info=err
)
return DucoData(
nodes={node.node_id: node for node in nodes},
node_actions=node_actions,
rssi_wifi=rssi_wifi,
time_filter_remain=time_filter_remain,
ventilation_temperatures=ventilation_temperatures,
)
+65
View File
@@ -18,6 +18,7 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfRatio,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
@@ -156,6 +157,70 @@ BOX_SENSOR_DESCRIPTIONS: tuple[DucoBoxSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
value_fn=lambda coordinator: coordinator.data.rssi_wifi,
),
DucoBoxSensorEntityDescription(
key="outdoor_air_temperature",
translation_key="outdoor_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_oda is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_oda
if coordinator.data.ventilation_temperatures
else None
),
),
DucoBoxSensorEntityDescription(
key="supply_air_temperature",
translation_key="supply_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_sup is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_sup
if coordinator.data.ventilation_temperatures
else None
),
),
DucoBoxSensorEntityDescription(
key="extract_air_temperature",
translation_key="extract_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_eta is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_eta
if coordinator.data.ventilation_temperatures
else None
),
),
DucoBoxSensorEntityDescription(
key="exhaust_air_temperature",
translation_key="exhaust_air_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
supported_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures is not None
and coordinator.data.ventilation_temperatures.temp_eha is not None
),
value_fn=lambda coordinator: (
coordinator.data.ventilation_temperatures.temp_eha
if coordinator.data.ventilation_temperatures
else None
),
),
)
@@ -73,6 +73,12 @@
}
},
"sensor": {
"exhaust_air_temperature": {
"name": "Exhaust air temperature"
},
"extract_air_temperature": {
"name": "Extract air temperature"
},
"filter_remaining": {
"name": "Filter remaining"
},
@@ -82,6 +88,12 @@
"iaq_rh": {
"name": "Humidity air quality index"
},
"outdoor_air_temperature": {
"name": "Outdoor air temperature"
},
"supply_air_temperature": {
"name": "Supply air temperature"
},
"target_flow_level": {
"name": "Target flow level"
},
+16
View File
@@ -23,6 +23,7 @@ from duco_connectivity import (
NodeMotorStateInfo,
NodeSensorInfo,
NodeVentilationInfo,
VentilationTemperatureInfo,
)
import pytest
@@ -178,6 +179,17 @@ def mock_lan_info() -> LanInfo:
)
@pytest.fixture
def mock_ventilation_temperature_info() -> VentilationTemperatureInfo:
"""Return mock ventilation temperatures in Celsius."""
return VentilationTemperatureInfo(
temp_oda=5.5,
temp_sup=18.2,
temp_eta=21.4,
temp_eha=8.1,
)
@pytest.fixture
def mock_nodes() -> list[Node]:
"""Return a list of nodes covering all supported types."""
@@ -235,6 +247,7 @@ def mock_duco_client(
mock_lan_info: LanInfo,
mock_nodes: list[Node],
mock_node_actions: NodeListActionItemList,
mock_ventilation_temperature_info: VentilationTemperatureInfo,
) -> Generator[AsyncMock]:
"""Return a mocked DucoClient used by both the integration and config flow."""
with (
@@ -255,6 +268,9 @@ def mock_duco_client(
client.async_get_node_configs.return_value = node_configs_from_nodes(mock_nodes)
client.async_get_node_actions.return_value = mock_node_actions
client.async_get_time_filter_remaining.return_value = 180
client.async_get_ventilation_temperature_info.return_value = (
mock_ventilation_temperature_info
)
client.async_get_diagnostics.return_value = [
DiagComponent(component="Ventilation", status="Ok")
]
@@ -835,6 +835,122 @@
'state': '90',
})
# ---
# name: test_sensor_entities_state[sensor.living_exhaust_air_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: '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': None,
'entity_id': 'sensor.living_exhaust_air_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Exhaust air temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Exhaust air temperature',
'platform': 'duco',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'exhaust_air_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_1_exhaust_air_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_entities_state[sensor.living_exhaust_air_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Exhaust air temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.living_exhaust_air_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '8.1',
})
# ---
# name: test_sensor_entities_state[sensor.living_extract_air_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: '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': None,
'entity_id': 'sensor.living_extract_air_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Extract air temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Extract air temperature',
'platform': 'duco',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'extract_air_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_1_extract_air_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_entities_state[sensor.living_extract_air_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Extract air temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.living_extract_air_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '21.4',
})
# ---
# name: test_sensor_entities_state[sensor.living_filter_remaining-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -890,6 +1006,64 @@
'state': '180',
})
# ---
# name: test_sensor_entities_state[sensor.living_outdoor_air_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: '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': None,
'entity_id': 'sensor.living_outdoor_air_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Outdoor air temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Outdoor air temperature',
'platform': 'duco',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'outdoor_air_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_1_outdoor_air_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_entities_state[sensor.living_outdoor_air_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Outdoor air temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.living_outdoor_air_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '5.5',
})
# ---
# name: test_sensor_entities_state[sensor.living_signal_strength-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
@@ -996,6 +1170,64 @@
'state': 'unknown',
})
# ---
# name: test_sensor_entities_state[sensor.living_supply_air_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: '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': None,
'entity_id': 'sensor.living_supply_air_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Supply air temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Supply air temperature',
'platform': 'duco',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'supply_air_temperature',
'unique_id': 'aa:bb:cc:dd:ee:ff_1_supply_air_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensor_entities_state[sensor.living_supply_air_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Living Supply air temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.living_supply_air_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '18.2',
})
# ---
# name: test_sensor_entities_state[sensor.living_target_flow_level-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
+41
View File
@@ -15,10 +15,12 @@ from duco_connectivity import (
LanInfo,
Node,
NodeListActionItemList,
VentilationTemperatureInfo,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.duco.const import SCAN_INTERVAL
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
@@ -158,6 +160,42 @@ async def test_setup_entry_ignores_lan_info_failures(
assert mock_config_entry.state is ConfigEntryState.LOADED
@pytest.mark.parametrize(
"exception",
[
pytest.param(DucoError("API error"), id="duco_error"),
pytest.param(DucoConnectionError("Connection refused"), id="connection_error"),
],
)
async def test_setup_entry_recovers_from_optional_temperature_capability_failure(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
exception: Exception,
) -> None:
"""Test an optional temperature capability is retried after a setup failure."""
mock_duco_client.async_get_ventilation_temperature_info.side_effect = [
exception,
VentilationTemperatureInfo(temp_oda=5.5),
]
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.LOADED
assert hass.states.get("sensor.living_outdoor_air_temperature") is None
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_outdoor_air_temperature")
assert state is not None
assert state.state == "5.5"
async def test_setup_entry_ignores_node_name_config_failures(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -280,6 +318,9 @@ async def test_setup_entry_creates_http_client(
mock_client_class.return_value.async_get_node_actions.return_value = (
mock_node_actions
)
(
mock_client_class.return_value.async_get_ventilation_temperature_info.return_value
) = VentilationTemperatureInfo()
mock_client_class.return_value.async_get_diagnostics.return_value = [
DiagComponent(component="Ventilation", status="Ok")
]
+57
View File
@@ -7,12 +7,14 @@ from unittest.mock import AsyncMock
from duco_connectivity import (
DucoConnectionError,
DucoError,
DucoUnsupportedCapabilityError,
Node,
NodeGeneralInfo,
NodeSensorInfo,
NodeType,
NodeVentilationInfo,
VentilationState,
VentilationTemperatureInfo,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
@@ -28,6 +30,12 @@ from . import setup_platform_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
FILTER_REMAINING_ENTITY_ID = "sensor.living_filter_remaining"
VENTILATION_TEMPERATURE_ENTITY_IDS = (
"sensor.living_outdoor_air_temperature",
"sensor.living_supply_air_temperature",
"sensor.living_extract_air_temperature",
"sensor.living_exhaust_air_temperature",
)
@pytest.mark.parametrize(
@@ -223,6 +231,55 @@ async def test_time_filter_remaining_missing_skips_sensor_creation(
assert hass.states.get(FILTER_REMAINING_ENTITY_ID) is None
async def test_ventilation_temperatures_missing_skip_sensor_creation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test unsupported ventilation temperatures never expose temperature states."""
mock_duco_client.async_get_ventilation_temperature_info.side_effect = [
DucoUnsupportedCapabilityError(400, "/info", '{"Code":3,"Result":"FAILED"}'),
VentilationTemperatureInfo(temp_oda=5.5),
]
await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
for entity_id in VENTILATION_TEMPERATURE_ENTITY_IDS:
assert hass.states.get(entity_id) is None
freezer.tick(SCAN_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
for entity_id in VENTILATION_TEMPERATURE_ENTITY_IDS:
assert hass.states.get(entity_id) is None
async def test_partial_ventilation_temperatures_only_expose_available_sensor_values(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_duco_client: AsyncMock,
) -> None:
"""Test only populated ventilation temperature fields are exposed as states."""
mock_duco_client.async_get_ventilation_temperature_info.return_value = (
VentilationTemperatureInfo(temp_oda=5.5, temp_eta=21.4)
)
await setup_platform_integration(hass, mock_config_entry, [Platform.SENSOR])
state = hass.states.get("sensor.living_outdoor_air_temperature")
assert state is not None
assert state.state == "5.5"
state = hass.states.get("sensor.living_extract_air_temperature")
assert state is not None
assert state.state == "21.4"
assert hass.states.get("sensor.living_supply_air_temperature") is None
assert hass.states.get("sensor.living_exhaust_air_temperature") is None
async def test_time_filter_remaining_transient_failure_recovers_sensor_creation(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,