From 8cc1dd8091c54cb410d65aa72ca4b507dacf8ded Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 25 Mar 2026 13:49:41 +0100 Subject: [PATCH] Add is_closed state attribute to valve (#165227) --- homeassistant/components/valve/__init__.py | 18 ++- .../snapshots/test_valve.ambr | 2 + tests/components/group/test_config_flow.py | 2 +- .../homee/snapshots/test_valve.ambr | 1 + .../hydrawise/snapshots/test_valve.ambr | 2 + .../matter/snapshots/test_valve.ambr | 1 + .../smartthings/snapshots/test_valve.ambr | 1 + .../components/tuya/snapshots/test_valve.ambr | 11 ++ .../components/valve/snapshots/test_init.ambr | 2 + tests/components/valve/test_init.py | 139 +++++++++++++++--- .../watergate/snapshots/test_valve.ambr | 1 + 11 files changed, 153 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/valve/__init__.py b/homeassistant/components/valve/__init__.py index 7df6f8eac51..aa25491a89b 100644 --- a/homeassistant/components/valve/__init__.py +++ b/homeassistant/components/valve/__init__.py @@ -62,6 +62,7 @@ class ValveEntityFeature(IntFlag): ATTR_CURRENT_POSITION = "current_position" +ATTR_IS_CLOSED = "is_closed" ATTR_POSITION = "position" @@ -189,11 +190,20 @@ class ValveEntity(Entity): @final @property - def state_attributes(self) -> dict[str, Any] | None: + def state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" - if not self.reports_position: - return None - return {ATTR_CURRENT_POSITION: self.current_valve_position} + data: dict[str, Any] = {} + + if self.reports_position: + if (current_valve_position := self.current_valve_position) is None: + data[ATTR_IS_CLOSED] = None + else: + data[ATTR_IS_CLOSED] = current_valve_position == 0 + data[ATTR_CURRENT_POSITION] = current_valve_position + else: + data[ATTR_IS_CLOSED] = self.is_closed + + return data @property def supported_features(self) -> ValveEntityFeature: diff --git a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr index 4a0da40a143..7d7f0eaf017 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_valve.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_valve.ambr @@ -4,6 +4,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Mock Title', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -19,6 +20,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Mock Title', + 'is_closed': False, 'supported_features': , }), 'context': , diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 6f02811b483..c570492e6b5 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -491,7 +491,7 @@ LOCK_ATTRS = [{"supported_features": 1}, {}] NOTIFY_ATTRS = [{"supported_features": 0}, {}] MEDIA_PLAYER_ATTRS = [{"supported_features": 0}, {}] SENSOR_ATTRS = [{"icon": "mdi:calculator"}, {"max_entity_id": "sensor.input_two"}] -VALVE_ATTRS = [{"supported_features": 0}, {}] +VALVE_ATTRS = [{"supported_features": 0}, {"is_closed": False}] @pytest.mark.parametrize( diff --git a/tests/components/homee/snapshots/test_valve.ambr b/tests/components/homee/snapshots/test_valve.ambr index 17ab84394b3..a2cde8927b0 100644 --- a/tests/components/homee/snapshots/test_valve.ambr +++ b/tests/components/homee/snapshots/test_valve.ambr @@ -42,6 +42,7 @@ 'current_position': 0, 'device_class': 'water', 'friendly_name': 'Test Valve Valve position', + 'is_closed': True, 'supported_features': , }), 'context': , diff --git a/tests/components/hydrawise/snapshots/test_valve.ambr b/tests/components/hydrawise/snapshots/test_valve.ambr index 0020e025608..3e820e6b294 100644 --- a/tests/components/hydrawise/snapshots/test_valve.ambr +++ b/tests/components/hydrawise/snapshots/test_valve.ambr @@ -42,6 +42,7 @@ 'attribution': 'Data provided by hydrawise.com', 'device_class': 'water', 'friendly_name': 'Zone One', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -95,6 +96,7 @@ 'attribution': 'Data provided by hydrawise.com', 'device_class': 'water', 'friendly_name': 'Zone Two', + 'is_closed': False, 'supported_features': , }), 'context': , diff --git a/tests/components/matter/snapshots/test_valve.ambr b/tests/components/matter/snapshots/test_valve.ambr index 69dfda626d4..d490e09d6fc 100644 --- a/tests/components/matter/snapshots/test_valve.ambr +++ b/tests/components/matter/snapshots/test_valve.ambr @@ -41,6 +41,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Mock Valve', + 'is_closed': True, 'supported_features': , }), 'context': , diff --git a/tests/components/smartthings/snapshots/test_valve.ambr b/tests/components/smartthings/snapshots/test_valve.ambr index 785b5cb17b8..f4d771822a7 100644 --- a/tests/components/smartthings/snapshots/test_valve.ambr +++ b/tests/components/smartthings/snapshots/test_valve.ambr @@ -41,6 +41,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'volvo', + 'is_closed': True, 'supported_features': , }), 'context': , diff --git a/tests/components/tuya/snapshots/test_valve.ambr b/tests/components/tuya/snapshots/test_valve.ambr index 30f7b9cd9a9..337d276fa72 100644 --- a/tests/components/tuya/snapshots/test_valve.ambr +++ b/tests/components/tuya/snapshots/test_valve.ambr @@ -41,6 +41,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'balkonbewässerung Valve', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -93,6 +94,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Garden Valve Yard Valve', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -145,6 +147,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 1', + 'is_closed': False, 'supported_features': , }), 'context': , @@ -197,6 +200,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 2', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -249,6 +253,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 3', + 'is_closed': False, 'supported_features': , }), 'context': , @@ -301,6 +306,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 4', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -353,6 +359,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 5', + 'is_closed': False, 'supported_features': , }), 'context': , @@ -405,6 +412,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 6', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -457,6 +465,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 7', + 'is_closed': False, 'supported_features': , }), 'context': , @@ -509,6 +518,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': '接HA水阀 Valve 8', + 'is_closed': True, 'supported_features': , }), 'context': , @@ -613,6 +623,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Sprinkler Cesare Valve', + 'is_closed': True, 'supported_features': , }), 'context': , diff --git a/tests/components/valve/snapshots/test_init.ambr b/tests/components/valve/snapshots/test_init.ambr index 815f902afad..859dcf2ca8b 100644 --- a/tests/components/valve/snapshots/test_init.ambr +++ b/tests/components/valve/snapshots/test_init.ambr @@ -3,6 +3,7 @@ StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Valve', + 'is_closed': False, 'supported_features': , }), 'context': , @@ -18,6 +19,7 @@ 'attributes': ReadOnlyDict({ 'current_position': 50, 'friendly_name': 'Valve', + 'is_closed': False, 'supported_features': , }), 'context': , diff --git a/tests/components/valve/test_init.py b/tests/components/valve/test_init.py index 1b4e6690054..bc7d95d24e3 100644 --- a/tests/components/valve/test_init.py +++ b/tests/components/valve/test_init.py @@ -6,6 +6,8 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.valve import ( + ATTR_CURRENT_POSITION, + ATTR_IS_CLOSED, DOMAIN, ValveDeviceClass, ValveEntity, @@ -234,7 +236,7 @@ async def test_services( # Test init all valves should be open assert is_open(hass, ent1) - assert is_open(hass, ent2) + assert is_open(hass, ent2, 50) # call basic toggle services await call_service(hass, SERVICE_TOGGLE, ent1) @@ -243,9 +245,9 @@ async def test_services( # entities without stop should be closed and with stop should be closing assert is_closed(hass, ent1) - assert is_closing(hass, ent2) + assert is_closing(hass, ent2, 50) ent2.finish_movement() - assert is_closed(hass, ent2) + assert is_closed(hass, ent2, 0) # call basic toggle services and set different valve position states await call_service(hass, SERVICE_TOGGLE, ent1) @@ -254,7 +256,9 @@ async def test_services( # entities should be in correct state depending on the SUPPORT_STOP feature and valve position assert is_open(hass, ent1) - assert is_opening(hass, ent2) + assert is_opening(hass, ent2, 0, True) + ent2.finish_movement() + assert is_open(hass, ent2, 100) # call basic toggle services await call_service(hass, SERVICE_TOGGLE, ent1) @@ -264,12 +268,16 @@ async def test_services( # entities should be in correct state depending on the SUPPORT_STOP feature and valve position assert is_closed(hass, ent1) assert not is_opening(hass, ent2) - assert not is_closing(hass, ent2) - assert is_closed(hass, ent2) + assert not is_closed(hass, ent2, 100) + assert is_closing(hass, ent2, 100) + ent2.finish_movement() + assert is_closed(hass, ent2, 0) await call_service(hass, SERVICE_SET_VALVE_POSITION, ent2, 50) await hass.async_block_till_done() - assert is_opening(hass, ent2) + assert is_opening(hass, ent2, 0, True) + ent2.finish_movement() + assert is_open(hass, ent2, 50) async def test_valve_device_class(hass: HomeAssistant) -> None: @@ -324,6 +332,45 @@ async def test_none_state(hass: HomeAssistant) -> None: assert pos_valve_with_none_is_closed_attr.state is None +async def test_is_closed_state_attribute(hass: HomeAssistant) -> None: + """Test the behavior of the is_closed state attribute.""" + binary_valve = MockBinaryValveEntity() + binary_valve.hass = hass + + assert binary_valve.state_attributes[ATTR_IS_CLOSED] is None + + binary_valve._attr_is_closed = True + assert binary_valve.state_attributes[ATTR_IS_CLOSED] is True + + binary_valve._attr_is_closed = False + assert binary_valve.state_attributes[ATTR_IS_CLOSED] is False + + pos_valve = MockValveEntity() + pos_valve.hass = hass + + assert pos_valve.state_attributes[ATTR_IS_CLOSED] is None + + # is_closed property is ignored for position reporting valves, + # so it should remain None even if set to True + pos_valve._attr_is_closed = True + assert pos_valve.state_attributes[ATTR_IS_CLOSED] is None + + # if current position is 0, the valve is closed + pos_valve._attr_current_valve_position = 0 + assert pos_valve.state_attributes[ATTR_IS_CLOSED] is True + + # if current position is greater than 0, the valve is not closed + pos_valve._attr_current_valve_position = 1 + assert pos_valve.state_attributes[ATTR_IS_CLOSED] is False + pos_valve._attr_current_valve_position = 50 + assert pos_valve.state_attributes[ATTR_IS_CLOSED] is False + + # if current position is None, the valve close state attribute + # is unknown and no fallback to the is_closed property is done + pos_valve._attr_current_valve_position = None + assert pos_valve.state_attributes[ATTR_IS_CLOSED] is None + + async def test_supported_features(hass: HomeAssistant) -> None: """Test valve entity with defaults.""" valve = MockValveEntity(features=None) @@ -347,21 +394,69 @@ def set_valve_position(ent, position) -> None: ent._values["current_valve_position"] = position -def is_open(hass: HomeAssistant, ent: ValveEntity) -> bool: +def _check_state( + hass: HomeAssistant, + ent: ValveEntity, + expected_state: str, + expected_position: int | None, + expected_is_closed: bool, +) -> bool: + """Check if the state of a valve is as expected.""" + state = hass.states.get(ent.entity_id) + correct_state = state.state == expected_state + correct_is_closed = state.attributes.get(ATTR_IS_CLOSED) == expected_is_closed + correct_position = state.attributes.get(ATTR_CURRENT_POSITION) == expected_position + return all([correct_state, correct_is_closed, correct_position]) + + +def is_open(hass: HomeAssistant, ent: ValveEntity, position: int | None = None) -> bool: + """Return if the valve is open based on the statemachine.""" + return _check_state( + hass, + ent, + expected_state=ValveState.OPEN, + expected_position=position, + expected_is_closed=False, + ) + + +def is_opening( + hass: HomeAssistant, + ent: ValveEntity, + position: int | None = None, + closed: bool = False, +) -> bool: + """Return if the valve is opening based on the statemachine.""" + return _check_state( + hass, + ent, + expected_state=ValveState.OPENING, + expected_position=position, + expected_is_closed=closed, + ) + + +def is_closed( + hass: HomeAssistant, ent: ValveEntity, position: int | None = None +) -> bool: """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, ValveState.OPEN) + return _check_state( + hass, + ent, + expected_state=ValveState.CLOSED, + expected_position=position, + expected_is_closed=True, + ) -def is_opening(hass: HomeAssistant, ent: ValveEntity) -> bool: - """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, ValveState.OPENING) - - -def is_closed(hass: HomeAssistant, ent: ValveEntity) -> bool: - """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, ValveState.CLOSED) - - -def is_closing(hass: HomeAssistant, ent: ValveEntity) -> bool: - """Return if the valve is closed based on the statemachine.""" - return hass.states.is_state(ent.entity_id, ValveState.CLOSING) +def is_closing( + hass: HomeAssistant, ent: ValveEntity, position: int | None = None +) -> bool: + """Return if the valve is closing based on the statemachine.""" + return _check_state( + hass, + ent, + expected_state=ValveState.CLOSING, + expected_position=position, + expected_is_closed=False, + ) diff --git a/tests/components/watergate/snapshots/test_valve.ambr b/tests/components/watergate/snapshots/test_valve.ambr index 1df1a0c748d..f248d81423e 100644 --- a/tests/components/watergate/snapshots/test_valve.ambr +++ b/tests/components/watergate/snapshots/test_valve.ambr @@ -4,6 +4,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'water', 'friendly_name': 'Sonic', + 'is_closed': False, 'supported_features': , }), 'context': ,