From 11d24e3de2d54e24395b45edb8b318e7861159eb Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 18 Jul 2026 08:23:12 +0200 Subject: [PATCH 1/7] Refactor if/else branches in Portainer sensor (#176716) --- homeassistant/components/portainer/sensor.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index 53cb302e5d8d3..d44b494b72d35 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -315,15 +315,9 @@ class PortainerVolumeSensorEntityDescription(SensorEntityDescription): PortainerStackSensorEntityDescription( key="stack_type", translation_key="stack_type", - value_fn=lambda data: ( - "swarm" - if data.stack.type == StackType.SWARM - else "compose" - if data.stack.type == StackType.COMPOSE - else "kubernetes" - if data.stack.type == StackType.KUBERNETES - else None - ), + value_fn=lambda data: { + stack.value: stack.name.lower() for stack in StackType + }.get(data.stack.type), device_class=SensorDeviceClass.ENUM, options=["swarm", "compose", "kubernetes"], entity_category=EntityCategory.DIAGNOSTIC, From abf344c3300a130e4c22a6df018e3cb814368c0b Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 18 Jul 2026 02:26:41 -0400 Subject: [PATCH 2/7] Bump pydrawise to 2026.7.0 (#176749) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index be00fad48545c..0485c6ea9718d 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2026.4.0"] + "requirements": ["pydrawise==2026.7.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4fbebcbb1ead3..65291243a1206 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2137,7 +2137,7 @@ pydiscovergy==3.0.2 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2026.4.0 +pydrawise==2026.7.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 From 7ada86af038e363f6309ff761b2c005a6f3cd923 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 18 Jul 2026 08:34:15 +0200 Subject: [PATCH 3/7] Refactor loop enum sensor portainer (#176709) --- homeassistant/components/portainer/sensor.py | 6 +- .../components/portainer/strings.json | 1 + .../portainer/snapshots/test_sensor.ambr | 84 +++++++++++-------- 3 files changed, 52 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/portainer/sensor.py b/homeassistant/components/portainer/sensor.py index d44b494b72d35..1d7cb14848c31 100644 --- a/homeassistant/components/portainer/sensor.py +++ b/homeassistant/components/portainer/sensor.py @@ -6,7 +6,7 @@ from typing import override from pyportainer import StackType -from pyportainer.models.docker import DockerSystemDF +from pyportainer.models.docker import DockerContainerState, DockerSystemDF from homeassistant.components.sensor import ( EntityCategory, @@ -84,7 +84,7 @@ class PortainerVolumeSensorEntityDescription(SensorEntityDescription): translation_key="container_state", value_fn=lambda data: data.container.state, device_class=SensorDeviceClass.ENUM, - options=["running", "exited", "paused", "restarting", "created", "dead"], + options=[state.value for state in DockerContainerState], ), PortainerContainerSensorEntityDescription( key="memory_limit", @@ -319,7 +319,7 @@ class PortainerVolumeSensorEntityDescription(SensorEntityDescription): stack.value: stack.name.lower() for stack in StackType }.get(data.stack.type), device_class=SensorDeviceClass.ENUM, - options=["swarm", "compose", "kubernetes"], + options=[stack.name.lower() for stack in StackType], entity_category=EntityCategory.DIAGNOSTIC, ), PortainerStackSensorEntityDescription( diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index d32ebe40ce424..6f4991c5b571a 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -105,6 +105,7 @@ "dead": "Dead", "exited": "Exited", "paused": "Paused", + "removing": "Removing", "restarting": "Restarting", "running": "Running" } diff --git a/tests/components/portainer/snapshots/test_sensor.ambr b/tests/components/portainer/snapshots/test_sensor.ambr index c63338ea4000a..8586f88d363f4 100644 --- a/tests/components/portainer/snapshots/test_sensor.ambr +++ b/tests/components/portainer/snapshots/test_sensor.ambr @@ -458,11 +458,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -502,11 +503,12 @@ : 'enum', : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05 State', : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -985,11 +987,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -1029,11 +1032,12 @@ : 'enum', : 'focused_einstein State', : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -1339,11 +1343,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -1383,11 +1388,12 @@ : 'enum', : 'funny_chatelet State', : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -2788,11 +2794,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -2832,11 +2839,12 @@ : 'enum', : 'practical_morse State', : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -3142,11 +3150,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -3186,11 +3195,12 @@ : 'enum', : 'serene_banach State', : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -3496,11 +3506,12 @@ 'area_id': None, 'capabilities': dict({ : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), @@ -3540,11 +3551,12 @@ : 'enum', : 'stoic_turing State', : list([ + 'created', + 'restarting', 'running', - 'exited', + 'removing', 'paused', - 'restarting', - 'created', + 'exited', 'dead', ]), }), From a5677e9ad1588a407849a949ef77f2d92dcd4c90 Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Sat, 18 Jul 2026 08:34:55 +0200 Subject: [PATCH 4/7] Remove progress feature Portainer (#176688) --- homeassistant/components/portainer/update.py | 14 +---------- .../portainer/snapshots/test_update.ambr | 24 +++++++++---------- 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/portainer/update.py b/homeassistant/components/portainer/update.py index da3d3f529e868..d8cd86263b300 100644 --- a/homeassistant/components/portainer/update.py +++ b/homeassistant/components/portainer/update.py @@ -112,9 +112,7 @@ def _async_add_new_containers( class PortainerContainerImageUpdateEntity(PortainerContainerEntity, UpdateEntity): """Representation of a Portainer container update.""" - _attr_supported_features = ( - UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS - ) + _attr_supported_features = UpdateEntityFeature.INSTALL entity_description: PortainerContainerUpdateEntityDescription @@ -130,7 +128,6 @@ def __init__( super().__init__(coordinator, entity_description, device_info, via_device) self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_name}_{entity_description.key}" - self._in_progress_old_version: str | None = None @override @property @@ -152,18 +149,11 @@ def latest_version(self) -> str | None: """Return latest version.""" return self.entity_description.latest_version(self.container_data.image_status) - @override - @property - def in_progress(self) -> bool: - """Return if an update is in progress.""" - return self._in_progress_old_version == self.installed_version - @override async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install update.""" - self._in_progress_old_version = self.installed_version try: await self.entity_description.update_func( self.coordinator.portainer, @@ -183,5 +173,3 @@ async def async_install( ) from ex else: await self.coordinator.async_request_refresh() - finally: - self._in_progress_old_version = None diff --git a/tests/components/portainer/snapshots/test_update.ambr b/tests/components/portainer/snapshots/test_update.ambr index 7f3bb09835ffc..9a75bddda14c6 100644 --- a/tests/components/portainer/snapshots/test_update.ambr +++ b/tests/components/portainer/snapshots/test_update.ambr @@ -30,7 +30,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05_container_image_update', 'unit_of_measurement': None, @@ -49,7 +49,7 @@ : None, : None, : None, - : , + : , : 'dashy_dashy.1.qgza68hnz4n1qvyz3iohynx05', : None, }), @@ -92,7 +92,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_focused_einstein_container_image_update', 'unit_of_measurement': None, @@ -111,7 +111,7 @@ : None, : None, : None, - : , + : , : 'focused_einstein', : None, }), @@ -154,7 +154,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_funny_chatelet_container_image_update', 'unit_of_measurement': None, @@ -173,7 +173,7 @@ : None, : None, : None, - : , + : , : 'funny_chatelet', : None, }), @@ -216,7 +216,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_practical_morse_container_image_update', 'unit_of_measurement': None, @@ -235,7 +235,7 @@ : None, : None, : None, - : , + : , : 'practical_morse', : None, }), @@ -278,7 +278,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_serene_banach_container_image_update', 'unit_of_measurement': None, @@ -297,7 +297,7 @@ : None, : None, : None, - : , + : , : 'serene_banach', : None, }), @@ -340,7 +340,7 @@ 'platform': 'portainer', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'container_image_update', 'unique_id': 'portainer_test_entry_123_stoic_turing_container_image_update', 'unit_of_measurement': None, @@ -359,7 +359,7 @@ : None, : None, : None, - : , + : , : 'stoic_turing', : None, }), From 514dbb953e0eda762cce41fe2999d1bcfee60091 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 18 Jul 2026 02:35:29 -0400 Subject: [PATCH 5/7] Update Roborock dynamic dock support (#176686) --- homeassistant/components/roborock/binary_sensor.py | 13 ++++++++----- homeassistant/components/roborock/sensor.py | 7 ++++--- tests/components/roborock/conftest.py | 1 - 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 57b16d4059c38..ca86acc60ddbe 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -98,7 +98,9 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.dirty_water_box_status, is_dock_entity=True, - support_fn=lambda api: api.wash_towel_mode is not None, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.DIRTY_WATER_BOX_STATUS + ), ), RoborockBinarySensorDescription( key="clean_box_empty", @@ -107,7 +109,9 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.clear_water_box_status, is_dock_entity=True, - support_fn=lambda api: api.wash_towel_mode is not None, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.CLEAR_WATER_BOX_STATUS + ), ), RoborockBinarySensorDescription( key="clean_fluid_empty", @@ -120,9 +124,8 @@ class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription): else None ), is_dock_entity=True, - support_fn=lambda api: ( - api.wash_towel_mode is not None - and api.device_features.is_clean_fluid_delivery_supported + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.CLEAN_FLUID_STATUS ), ), RoborockBinarySensorDescription( diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 7cb45220f160f..bd81c9e1f73e7 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -19,6 +19,7 @@ ZeoState, ) from roborock.data.b01_q10.b01_q10_code_mappings import YXDeviceState +from roborock.data.v1.v1_containers import StatusField, StatusV2 from roborock.devices.traits.b01.q10.status import StatusTrait as Q10StatusTrait from roborock.devices.traits.v1 import PropertiesApi from roborock.roborock_message import RoborockDyadDataProtocol, RoborockZeoProtocol @@ -259,9 +260,9 @@ def _dock_error_value_fn(state: DeviceState) -> str | None: device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), is_dock_entity=True, - # Only available with more than just the basic dock. Dust collection - # mode is a proxy for any more complex dock type (e.g. Auto-empty). - support_fn=lambda api: api.dust_collection_mode is not None, + support_fn=lambda api: api.device_features.is_field_supported( + StatusV2, StatusField.DOCK_ERROR_STATUS + ), ), RoborockSensorDescription( key="mop_clean_remaining", diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 43e29665ac295..f9346b712b739 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -424,7 +424,6 @@ def make_device_features() -> Mock: device_features = MagicMock(spec=DeviceFeaturesTrait) device_features.is_supported_drying = True device_features.is_support_water_mode = True - device_features.is_clean_fluid_delivery_supported = True device_features.is_support_clean_estimate = True device_features.is_clean_route_setting_supported = True device_features.is_field_supported.return_value = True From 791263ada562c28d67547e094776ffc115e95f56 Mon Sep 17 00:00:00 2001 From: Ronald van der Meer Date: Sat, 18 Jul 2026 09:37:39 +0200 Subject: [PATCH 6/7] Add Duco ventilation temperature sensors (#176621) --- homeassistant/components/duco/coordinator.py | 31 ++- homeassistant/components/duco/sensor.py | 65 +++++ homeassistant/components/duco/strings.json | 12 + tests/components/duco/conftest.py | 16 ++ .../duco/snapshots/test_sensor.ambr | 232 ++++++++++++++++++ tests/components/duco/test_init.py | 41 ++++ tests/components/duco/test_sensor.py | 57 +++++ 7 files changed, 452 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/duco/coordinator.py b/homeassistant/components/duco/coordinator.py index 10cbf619c96eb..e0755b153f091 100644 --- a/homeassistant/components/duco/coordinator.py +++ b/homeassistant/components/duco/coordinator.py @@ -10,8 +10,15 @@ 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 @@ 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 @@ def __init__( 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 @@ async def _async_update_data(self) -> 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 faad3737c2716..63e8f16cd27d1 100644 --- a/homeassistant/components/duco/sensor.py +++ b/homeassistant/components/duco/sensor.py @@ -18,6 +18,7 @@ SIGNAL_STRENGTH_DECIBELS_MILLIWATT, EntityCategory, UnitOfRatio, + UnitOfTemperature, UnitOfTime, ) from homeassistant.core import HomeAssistant, callback @@ -156,6 +157,70 @@ class DucoBoxSensorEntityDescription(SensorEntityDescription): 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 2761903e336b7..4f5eb782f93ac 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 b6963ad8a7615..655dd0dfc935e 100644 --- a/tests/components/duco/conftest.py +++ b/tests/components/duco/conftest.py @@ -23,6 +23,7 @@ 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 3b2801d29af01..1be688fcc2370 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 24f906507d376..3cf9aee5b3c92 100644 --- a/tests/components/duco/test_init.py +++ b/tests/components/duco/test_init.py @@ -15,10 +15,12 @@ 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 563ab57fb6b73..f3c978d0822bd 100644 --- a/tests/components/duco/test_sensor.py +++ b/tests/components/duco/test_sensor.py @@ -7,12 +7,14 @@ 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 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, From 8aa43c6a148cb54eb9f71ac5e8f491a55563821d Mon Sep 17 00:00:00 2001 From: Hamish Date: Sat, 18 Jul 2026 18:13:43 +0930 Subject: [PATCH 7/7] Implement diagnostics for Gatus (4/4) (#176758) --- homeassistant/components/gatus/diagnostics.py | 31 +++++++++++++++++++ .../components/gatus/quality_scale.yaml | 2 +- .../gatus/snapshots/test_diagnostics.ambr | 18 +++++++++++ tests/components/gatus/test_diagnostics.py | 28 +++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/gatus/diagnostics.py create mode 100644 tests/components/gatus/snapshots/test_diagnostics.ambr create mode 100644 tests/components/gatus/test_diagnostics.py diff --git a/homeassistant/components/gatus/diagnostics.py b/homeassistant/components/gatus/diagnostics.py new file mode 100644 index 0000000000000..eff962bc5caed --- /dev/null +++ b/homeassistant/components/gatus/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics support for Gatus.""" + +from typing import Any + +from homeassistant.core import HomeAssistant + +from .coordinator import GatusConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: GatusConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data + return { + "data": [ + { + "key": ep.key, + "name": ep.name, + "group": ep.group, + "results": [ + { + "success": r.success, + "status": r.status, + } + for r in ep.results + ], + } + for ep in coordinator.data.values() + ], + } diff --git a/homeassistant/components/gatus/quality_scale.yaml b/homeassistant/components/gatus/quality_scale.yaml index dab6799b0a88b..2024f5508fd8a 100644 --- a/homeassistant/components/gatus/quality_scale.yaml +++ b/homeassistant/components/gatus/quality_scale.yaml @@ -49,7 +49,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: Integration does not support discovery. diff --git a/tests/components/gatus/snapshots/test_diagnostics.ambr b/tests/components/gatus/snapshots/test_diagnostics.ambr new file mode 100644 index 0000000000000..96507ed1cedc4 --- /dev/null +++ b/tests/components/gatus/snapshots/test_diagnostics.ambr @@ -0,0 +1,18 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': list([ + dict({ + 'group': 'Core', + 'key': 'backend_service', + 'name': 'Backend Service', + 'results': list([ + dict({ + 'status': 200, + 'success': True, + }), + ]), + }), + ]), + }) +# --- diff --git a/tests/components/gatus/test_diagnostics.py b/tests/components/gatus/test_diagnostics.py new file mode 100644 index 0000000000000..ae5a4023cfde4 --- /dev/null +++ b/tests/components/gatus/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Test Gatus diagnostics.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_gatus_client") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test generating diagnostics for Gatus config entry.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + )