1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 00:20:30 +01:00

Add is_closed state attribute to valve (#165227)

This commit is contained in:
Michael
2026-03-25 13:49:41 +01:00
committed by GitHub
parent 83ff038188
commit 8cc1dd8091
11 changed files with 153 additions and 27 deletions

View File

@@ -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:

View File

@@ -4,6 +4,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Mock Title',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -19,6 +20,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Mock Title',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,

View File

@@ -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(

View File

@@ -42,6 +42,7 @@
'current_position': 0,
'device_class': 'water',
'friendly_name': 'Test Valve Valve position',
'is_closed': True,
'supported_features': <ValveEntityFeature: 4>,
}),
'context': <ANY>,

View File

@@ -42,6 +42,7 @@
'attribution': 'Data provided by hydrawise.com',
'device_class': 'water',
'friendly_name': 'Zone One',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -95,6 +96,7 @@
'attribution': 'Data provided by hydrawise.com',
'device_class': 'water',
'friendly_name': 'Zone Two',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,

View File

@@ -41,6 +41,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Mock Valve',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,

View File

@@ -41,6 +41,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'volvo',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,

View File

@@ -41,6 +41,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'balkonbewässerung Valve',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -93,6 +94,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Garden Valve Yard Valve',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -145,6 +147,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 1',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -197,6 +200,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 2',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -249,6 +253,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 3',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -301,6 +306,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 4',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -353,6 +359,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 5',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -405,6 +412,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 6',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -457,6 +465,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 7',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -509,6 +518,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': '接HA水阀 Valve 8',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -613,6 +623,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Sprinkler Cesare Valve',
'is_closed': True,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,

View File

@@ -3,6 +3,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Valve',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,
@@ -18,6 +19,7 @@
'attributes': ReadOnlyDict({
'current_position': 50,
'friendly_name': 'Valve',
'is_closed': False,
'supported_features': <ValveEntityFeature: 15>,
}),
'context': <ANY>,

View File

@@ -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,
)

View File

@@ -4,6 +4,7 @@
'attributes': ReadOnlyDict({
'device_class': 'water',
'friendly_name': 'Sonic',
'is_closed': False,
'supported_features': <ValveEntityFeature: 3>,
}),
'context': <ANY>,