diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 10cbf619c96e..e0755b153f09 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -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, ) diff --git a/homeassistant/components/duco/sensor.py b/homeassistant/components/duco/sensor.py index faad3737c271..63e8f16cd27d 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -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 + ), + ), ) diff --git a/homeassistant/components/duco/strings.json b/homeassistant/components/duco/strings.json index 2761903e336b..4f5eb782f93a 100644 --- a/homeassistant/components/duco/strings.json +++ b/homeassistant/components/duco/strings.json @@ -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" }, diff --git a/tests/components/duco/conftest.py b/tests/components/duco/conftest.py index b6963ad8a761..655dd0dfc935 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -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") ] diff --git a/tests/components/duco/snapshots/test_sensor.ambr b/tests/components/duco/snapshots/test_sensor.ambr index 3b2801d29af0..1be688fcc237 100644 --- a/tests/components/duco/snapshots/test_sensor.ambr +++ b/tests/components/duco/snapshots/test_sensor.ambr @@ -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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Exhaust air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_exhaust_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Exhaust air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_exhaust_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '8.1', + }) +# --- +# name: test_sensor_entities_state[sensor.living_extract_air_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Extract air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_extract_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Extract air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_extract_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Outdoor air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_outdoor_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Outdoor air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_outdoor_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + '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({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + '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': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Supply air temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + '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': , + }) +# --- +# name: test_sensor_entities_state[sensor.living_supply_air_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Living Supply air temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.living_supply_air_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.2', + }) +# --- # name: test_sensor_entities_state[sensor.living_target_flow_level-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/duco/test_init.py b/tests/components/duco/test_init.py index 24f906507d37..3cf9aee5b3c9 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -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") ] diff --git a/tests/components/duco/test_sensor.py b/tests/components/duco/test_sensor.py index 563ab57fb6b7..f3c978d0822b 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -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,