Add ZHA valve platform (#183740)

This commit is contained in:
puddly
2026-09-30 16:25:42 +02:00
committed by GitHub
parent 1f7be4e0fe
commit c01c12368f
3 changed files with 453 additions and 0 deletions
+1
View File
@@ -107,6 +107,7 @@ PLATFORMS = (
Platform.SIREN,
Platform.SWITCH,
Platform.UPDATE,
Platform.VALVE,
)
+133
View File
@@ -0,0 +1,133 @@
"""Support for ZHA valves."""
import functools
from typing import override
from zha.application.platforms.valve.const import (
ValveEntityFeature as ZHAValveEntityFeature,
)
from homeassistant.components.valve import (
ValveDeviceClass,
ValveEntity,
ValveEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .entity import ZHASupportedFeaturesEntity
from .helpers import (
SIGNAL_ADD_ENTITIES,
async_add_entities as zha_async_add_entities,
convert_zha_error_to_ha_error,
get_zha_data,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Zigbee Home Automation valve from config entry."""
zha_data = get_zha_data(hass)
entities_to_create = zha_data.platforms[Platform.VALVE]
unsub = async_dispatcher_connect(
hass,
SIGNAL_ADD_ENTITIES,
functools.partial(
zha_async_add_entities, async_add_entities, ZHAValve, entities_to_create
),
)
config_entry.async_on_unload(unsub)
class ZHAValve(ZHASupportedFeaturesEntity, ValveEntity):
"""Representation of a ZHA valve."""
@staticmethod
@functools.cache
@override
def _convert_supported_features(
zha_features: ZHAValveEntityFeature,
) -> ValveEntityFeature:
"""Convert ZHA valve features to HA valve features."""
features = ValveEntityFeature(0)
if ZHAValveEntityFeature.OPEN in zha_features:
features |= ValveEntityFeature.OPEN
if ZHAValveEntityFeature.CLOSE in zha_features:
features |= ValveEntityFeature.CLOSE
if ZHAValveEntityFeature.SET_POSITION in zha_features:
features |= ValveEntityFeature.SET_POSITION
if ZHAValveEntityFeature.STOP in zha_features:
features |= ValveEntityFeature.STOP
return features
@override
def _update_capability_attrs(self) -> None:
"""Re-derive capability attributes from the cached state."""
super()._update_capability_attrs()
device_class = self._zha_state.device_class
self._attr_device_class = (
ValveDeviceClass(device_class) if device_class is not None else None
)
self._attr_reports_position = self._zha_state.reports_position
@property
@override
def current_valve_position(self) -> int | None:
"""Return the current position of the valve."""
return self._zha_state.current_position
@property
@override
def is_opening(self) -> bool | None:
"""Return if the valve is opening."""
return self._zha_state.is_opening
@property
@override
def is_closing(self) -> bool | None:
"""Return if the valve is closing."""
return self._zha_state.is_closing
@property
@override
def is_closed(self) -> bool | None:
"""Return if the valve is closed."""
return self._zha_state.is_closed
@convert_zha_error_to_ha_error()
@override
async def async_open_valve(self) -> None:
"""Open the valve."""
await self.entity_data.entity.async_open_valve()
self.async_write_ha_state()
@convert_zha_error_to_ha_error()
@override
async def async_close_valve(self) -> None:
"""Close the valve."""
await self.entity_data.entity.async_close_valve()
self.async_write_ha_state()
@convert_zha_error_to_ha_error()
@override
async def async_set_valve_position(self, position: int) -> None:
"""Move the valve to a specific position."""
await self.entity_data.entity.async_set_valve_position(position=position)
self.async_write_ha_state()
@convert_zha_error_to_ha_error()
@override
async def async_stop_valve(self) -> None:
"""Stop the valve."""
await self.entity_data.entity.async_stop_valve()
self.async_write_ha_state()
+319
View File
@@ -0,0 +1,319 @@
"""Test ZHA valve."""
from collections.abc import Callable, Coroutine, Generator
from unittest.mock import patch
import pytest
from zha.application.platforms import ENTITY_REGISTRY, ClusterMatch
from zha.application.platforms.valve import BaseValve
from zha.application.platforms.valve.const import (
ValveDeviceClass as ZHAValveDeviceClass,
ValveEntityFeature as ZHAValveEntityFeature,
)
from zha.exceptions import ZHAException
from zigpy.const import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
from zigpy.device import ZigbeeDevice
from zigpy.profiles import zha
from zigpy.zcl.clusters import general
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
DOMAIN as VALVE_DOMAIN,
ValveDeviceClass,
ValveEntityFeature,
ValveState,
)
from homeassistant.components.zha.helpers import (
ZHADeviceProxy,
ZHAGatewayProxy,
get_zha_gateway,
get_zha_gateway_proxy,
)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
SERVICE_STOP_VALVE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .common import find_entity_id
class FakeValve(BaseValve):
"""Valve entity that only opens and closes."""
_unique_id_suffix = "fake"
_attr_device_class = ZHAValveDeviceClass.WATER
_cluster_match = ClusterMatch(
server_clusters=frozenset({general.OnOff.cluster_id}),
)
_closed: bool | None = None
@property
def is_closed(self) -> bool | None:
"""Return if the valve is closed."""
return self._closed
async def async_open_valve(self) -> None:
"""Open the valve."""
self._closed = False
self.maybe_emit_state_changed_event()
async def async_close_valve(self) -> None:
"""Close the valve."""
self._closed = True
self.maybe_emit_state_changed_event()
class FakeGasValve(FakeValve):
"""Valve entity with a different device class."""
_attr_device_class = ZHAValveDeviceClass.GAS
class FakeNoDeviceClassValve(FakeValve):
"""Valve entity without a device class."""
_attr_device_class = None
class FakePositionValve(FakeValve):
"""Valve entity that reports, sets and stops its position."""
_attr_reports_position = True
_attr_supported_features = (
ZHAValveEntityFeature.OPEN
| ZHAValveEntityFeature.CLOSE
| ZHAValveEntityFeature.SET_POSITION
| ZHAValveEntityFeature.STOP
)
_position: int | None = None
_target: int | None = None
@property
def current_valve_position(self) -> int | None:
"""Return the current position of the valve."""
return self._position
@property
def is_opening(self) -> bool | None:
"""Return if the valve is opening."""
return self._target is not None and self._target > (self._position or 0)
@property
def is_closing(self) -> bool | None:
"""Return if the valve is closing."""
return self._target is not None and self._target < (self._position or 0)
async def async_set_valve_position(self, position: int) -> None:
"""Start moving the valve to a specific position."""
self._target = position
self.maybe_emit_state_changed_event()
async def async_stop_valve(self) -> None:
"""Stop the valve halfway to its target."""
assert self._target is not None
self._position = ((self._position or 0) + self._target) // 2
self._target = None
self.maybe_emit_state_changed_event()
class FakeFailingValve(FakeValve):
"""Valve entity whose commands fail."""
async def async_open_valve(self) -> None:
"""Fail to open the valve."""
raise ZHAException("Failed to open valve")
@pytest.fixture(autouse=True)
def valve_platform_only() -> Generator[None]:
"""Only set up the valve and required base platforms to speed up tests."""
with patch(
"homeassistant.components.zha.PLATFORMS",
(Platform.SENSOR, Platform.VALVE),
):
yield
@pytest.fixture
def valve_class() -> type[FakeValve]:
"""Return the fake valve entity class to discover."""
return FakeValve
@pytest.fixture(autouse=True)
def register_fake_valve(valve_class: type[FakeValve]) -> Generator[None]:
"""Make zha discover the fake valve entity on the OnOff server cluster."""
with patch.dict(
ENTITY_REGISTRY,
{
general.OnOff.cluster_id: [
*ENTITY_REGISTRY[general.OnOff.cluster_id],
valve_class,
]
},
):
yield
async def _setup_device(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., ZigbeeDevice],
) -> str:
"""Join a device with an OnOff server cluster, return its valve entity ID."""
await setup_zha()
gateway = get_zha_gateway(hass)
gateway_proxy: ZHAGatewayProxy = get_zha_gateway_proxy(hass)
zigpy_device = zigpy_device_mock(
{
1: {
SIG_EP_PROFILE: zha.PROFILE_ID,
SIG_EP_TYPE: zha.DeviceType.ON_OFF_OUTPUT,
SIG_EP_INPUT: [general.Basic.cluster_id, general.OnOff.cluster_id],
SIG_EP_OUTPUT: [],
}
},
)
gateway.get_or_create_device(zigpy_device)
await gateway.async_device_initialized(zigpy_device)
await hass.async_block_till_done(wait_background_tasks=True)
zha_device_proxy: ZHADeviceProxy = gateway_proxy.get_device_proxy(zigpy_device.ieee)
entity_id = find_entity_id(Platform.VALVE, zha_device_proxy, hass)
assert entity_id is not None
return entity_id
@pytest.mark.parametrize(
("valve_class", "device_class"),
[
pytest.param(FakeValve, ValveDeviceClass.WATER, id="water"),
pytest.param(FakeGasValve, ValveDeviceClass.GAS, id="gas"),
pytest.param(FakeNoDeviceClassValve, None, id="no_device_class"),
],
)
async def test_valve_entity(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., ZigbeeDevice],
device_class: ValveDeviceClass | None,
) -> None:
"""Test ZHA valve entity is created with the capabilities of the zha entity."""
entity_id = await _setup_device(hass, setup_zha, zigpy_device_mock)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN
assert state.attributes.get(ATTR_DEVICE_CLASS) == device_class
assert state.attributes[ATTR_SUPPORTED_FEATURES] == (
ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
)
assert ATTR_CURRENT_POSITION not in state.attributes
async def test_valve_open_close(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., ZigbeeDevice],
) -> None:
"""Test opening and closing a valve that does not report its position."""
entity_id = await _setup_device(hass, setup_zha, zigpy_device_mock)
await hass.services.async_call(
VALVE_DOMAIN, SERVICE_CLOSE_VALVE, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == ValveState.CLOSED
await hass.services.async_call(
VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == ValveState.OPEN
@pytest.mark.parametrize("valve_class", [FakePositionValve])
async def test_valve_position(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., ZigbeeDevice],
) -> None:
"""Test moving and stopping a valve that reports its position."""
entity_id = await _setup_device(hass, setup_zha, zigpy_device_mock)
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNKNOWN
assert state.attributes[ATTR_SUPPORTED_FEATURES] == (
ValveEntityFeature.OPEN
| ValveEntityFeature.CLOSE
| ValveEntityFeature.SET_POSITION
| ValveEntityFeature.STOP
)
assert state.attributes[ATTR_CURRENT_POSITION] is None
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_SET_VALVE_POSITION,
{ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 80},
blocking=True,
)
assert hass.states.get(entity_id).state == ValveState.OPENING
await hass.services.async_call(
VALVE_DOMAIN, SERVICE_STOP_VALVE, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
state = hass.states.get(entity_id)
assert state.state == ValveState.OPEN
assert state.attributes[ATTR_CURRENT_POSITION] == 40
# Opening a positional valve moves it to 100
await hass.services.async_call(
VALVE_DOMAIN, SERVICE_OPEN_VALVE, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == ValveState.OPENING
await hass.services.async_call(
VALVE_DOMAIN, SERVICE_STOP_VALVE, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 70
# Closing a positional valve moves it to 0
await hass.services.async_call(
VALVE_DOMAIN, SERVICE_CLOSE_VALVE, {ATTR_ENTITY_ID: entity_id}, blocking=True
)
assert hass.states.get(entity_id).state == ValveState.CLOSING
@pytest.mark.parametrize("valve_class", [FakeFailingValve])
async def test_valve_command_failure(
hass: HomeAssistant,
setup_zha: Callable[..., Coroutine[None]],
zigpy_device_mock: Callable[..., ZigbeeDevice],
) -> None:
"""Test ZHA errors are raised as Home Assistant errors."""
entity_id = await _setup_device(hass, setup_zha, zigpy_device_mock)
with pytest.raises(HomeAssistantError, match="Failed to open valve"):
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_OPEN_VALVE,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert hass.states.get(entity_id).state == STATE_UNKNOWN