From 3b33d0d4d63bf3ced4f6ab0922771d9d92281e9c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sun, 22 Feb 2026 18:04:35 -0500 Subject: [PATCH 1/3] WIP: valve platform --- zha/application/discovery.py | 2 + zha/application/platforms/valve/__init__.py | 106 ++++++++++++++++++++ zha/application/platforms/valve/const.py | 33 ++++++ 3 files changed, 141 insertions(+) create mode 100644 zha/application/platforms/valve/__init__.py create mode 100644 zha/application/platforms/valve/const.py diff --git a/zha/application/discovery.py b/zha/application/discovery.py index 493b48be4..a946f8e15 100644 --- a/zha/application/discovery.py +++ b/zha/application/discovery.py @@ -46,6 +46,7 @@ siren, switch, update, + valve, ) # importing cluster handlers updates registries @@ -90,6 +91,7 @@ Platform.SENSOR, Platform.SIREN, Platform.SWITCH, + Platform.VALVE, Platform.UPDATE, ) diff --git a/zha/application/platforms/valve/__init__.py b/zha/application/platforms/valve/__init__.py new file mode 100644 index 000000000..3725088fc --- /dev/null +++ b/zha/application/platforms/valve/__init__.py @@ -0,0 +1,106 @@ +"""Valves on Zigbee Home Automation networks.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from zha.application import Platform +from zha.application.platforms import BaseEntity +from zha.application.platforms.valve.const import ( + ATTR_CURRENT_POSITION, + ValveEntityFeature, + ValveState, +) + + +class BaseValve(BaseEntity, ABC): + """Abstract base class for ZHA valve entities.""" + + PLATFORM = Platform.VALVE + + _attr_supported_features: ValveEntityFeature = ValveEntityFeature(0) + _attr_translation_key: str = "valve" + _attr_primary_weight = 10 + + @property + def supported_features(self) -> ValveEntityFeature: + """Return supported features.""" + return self._attr_supported_features + + @property + @abstractmethod + def reports_position(self) -> bool: + """Return if the valve reports position.""" + + @property + @abstractmethod + def current_valve_position(self) -> int | None: + """Return the current valve position.""" + + @property + @abstractmethod + def is_closed(self) -> bool | None: + """Return if the valve is closed.""" + + @property + @abstractmethod + def is_opening(self) -> bool | None: + """Return if the valve is opening.""" + + @property + @abstractmethod + def is_closing(self) -> bool | None: + """Return if the valve is closing.""" + + @property + def valve_state(self) -> ValveState | None: + """Return the current valve state.""" + if self.is_opening: + return ValveState.OPENING + if self.is_closing: + return ValveState.CLOSING + + if self.reports_position: + if (position := self.current_valve_position) is None: + return None + + return ValveState.CLOSED if position == 0 else ValveState.OPEN + + if (is_closed := self.is_closed) is None: + return None + + return ValveState.CLOSED if is_closed else ValveState.OPEN + + @property + def state(self) -> dict[str, Any]: + """Return the state of the valve.""" + response = super().state + response.update( + { + ATTR_CURRENT_POSITION: ( + self.current_valve_position if self.reports_position else None + ), + "state": self.valve_state, + "is_opening": self.is_opening, + "is_closing": self.is_closing, + "is_closed": self.is_closed, + } + ) + return response + + @abstractmethod + async def async_open_valve(self) -> None: + """Open the valve.""" + + @abstractmethod + async def async_close_valve(self) -> None: + """Close the valve.""" + + @abstractmethod + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + + @abstractmethod + async def async_stop_valve(self) -> None: + """Stop the valve.""" diff --git a/zha/application/platforms/valve/const.py b/zha/application/platforms/valve/const.py new file mode 100644 index 000000000..002fb9cd4 --- /dev/null +++ b/zha/application/platforms/valve/const.py @@ -0,0 +1,33 @@ +"""Constants for the valve platform.""" + +from enum import IntFlag, StrEnum +from typing import Final + +ATTR_CURRENT_POSITION: Final[str] = "current_position" +ATTR_POSITION: Final[str] = "position" + + +class ValveState(StrEnum): + """State of Valve entities.""" + + OPENING = "opening" + CLOSING = "closing" + CLOSED = "closed" + OPEN = "open" + + +class ValveDeviceClass(StrEnum): + """Device class for valve.""" + + # Refer to the valve dev docs for device class descriptions + WATER = "water" + GAS = "gas" + + +class ValveEntityFeature(IntFlag): + """Supported features of the valve entity.""" + + OPEN = 1 + CLOSE = 2 + SET_POSITION = 4 + STOP = 8 From 438e3f4bf905c43e95b68f7a6695cfb29706b65f Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:15:44 -0500 Subject: [PATCH 2/3] WIP support via ZCL but keep opt-in --- tests/test_valve.py | 122 +++++++++++++ zha/application/platforms/valve/__init__.py | 180 +++++++++++++++++++- 2 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 tests/test_valve.py diff --git a/tests/test_valve.py b/tests/test_valve.py new file mode 100644 index 000000000..0aba6f94e --- /dev/null +++ b/tests/test_valve.py @@ -0,0 +1,122 @@ +"""Test zha valve.""" + +from unittest.mock import AsyncMock, call, patch + +import pytest +import zigpy.zcl.foundation as zcl_f + +from tests.common import get_entity, join_zigpy_device, zigpy_device_from_json +from zha.application import Platform +from zha.application.gateway import Gateway +from zha.application.helpers import DeviceOverridesConfiguration +from zha.application.platforms.valve import Valve +from zha.application.platforms.valve.const import ValveEntityFeature + + +async def test_valve_not_discovered_by_default(zha_gateway: Gateway) -> None: + """Test that valves are not discovered by default.""" + zigpy_device = await zigpy_device_from_json( + zha_gateway.application_controller, + "tests/data/devices/sonoff-swv.json", + ) + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + + with pytest.raises(KeyError): + get_entity(zha_device, platform=Platform.VALVE) + + get_entity( + zha_device, + platform=Platform.SWITCH, + qualifier_func=lambda entity: ( + entity.cluster_handlers.get("on_off") + and entity.cluster_handlers["on_off"].cluster + == zigpy_device.endpoints[1].on_off + ), + ) + + +async def test_valve_discovered_via_platform_override(zha_gateway: Gateway) -> None: + """Test valve discovery when overridden via device configuration.""" + zigpy_device = await zigpy_device_from_json( + zha_gateway.application_controller, + "tests/data/devices/sonoff-swv.json", + ) + + zha_gateway.config.config.device_overrides = { + f"{zigpy_device.ieee}-1": DeviceOverridesConfiguration(type=Platform.VALVE) + } + + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + + valve = get_entity( + zha_device, + platform=Platform.VALVE, + exact_entity_type=Valve, + qualifier_func=lambda entity: ( + entity.cluster_handlers.get("on_off") + and entity.cluster_handlers["on_off"].cluster + == zigpy_device.endpoints[1].on_off + ), + ) + + assert valve.supported_features == ( + ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + ) + assert valve.reports_position is False + + with pytest.raises(KeyError): + get_entity( + zha_device, + platform=Platform.SWITCH, + qualifier_func=lambda entity: ( + entity.cluster_handlers.get("on_off") + and entity.cluster_handlers["on_off"].cluster + == zigpy_device.endpoints[1].on_off + ), + ) + + +async def test_valve_level_control_commands(zha_gateway: Gateway) -> None: + """Test level-based valve set_position and stop with platform override.""" + zigpy_device = await zigpy_device_from_json( + zha_gateway.application_controller, + "tests/data/devices/sinope-technologies-va4220zb.json", + ) + + zha_gateway.config.config.device_overrides = { + f"{zigpy_device.ieee}-1": DeviceOverridesConfiguration(type=Platform.VALVE) + } + + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + valve = get_entity( + zha_device, + platform=Platform.VALVE, + exact_entity_type=Valve, + qualifier_func=lambda entity: ( + entity.cluster_handlers.get("on_off") + and entity.cluster_handlers["on_off"].cluster + == zigpy_device.endpoints[1].on_off + ), + ) + + assert valve.reports_position is True + assert valve.supported_features & ValveEntityFeature.SET_POSITION + assert valve.supported_features & ValveEntityFeature.STOP + + level_cluster = zigpy_device.endpoints[1].level + + with patch.object( + level_cluster, + "move_to_level_with_on_off", + AsyncMock(return_value=[0, zcl_f.Status.SUCCESS]), + ) as move_to_level_with_on_off: + await valve.async_set_valve_position(position=47) + await zha_gateway.async_block_till_done() + assert move_to_level_with_on_off.mock_calls == [call(120, 1)] + + with patch.object( + level_cluster, "stop", AsyncMock(return_value=[0, zcl_f.Status.SUCCESS]) + ) as stop: + await valve.async_stop_valve() + await zha_gateway.async_block_till_done() + assert stop.mock_calls == [call()] diff --git a/zha/application/platforms/valve/__init__.py b/zha/application/platforms/valve/__init__.py index 3725088fc..6fadffedc 100644 --- a/zha/application/platforms/valve/__init__.py +++ b/zha/application/platforms/valve/__init__.py @@ -3,15 +3,42 @@ from __future__ import annotations from abc import ABC, abstractmethod -from typing import Any +from typing import TYPE_CHECKING, Any, cast + +from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.foundation import Status from zha.application import Platform -from zha.application.platforms import BaseEntity +from zha.application.platforms import ( + BaseEntity, + ClusterHandlerMatch, + PlatformEntity, + PlatformFeatureGroup, + register_entity, +) from zha.application.platforms.valve.const import ( ATTR_CURRENT_POSITION, ValveEntityFeature, ValveState, ) +from zha.exceptions import ZHAException +from zha.zigbee.cluster_handlers import ClusterAttributeUpdatedEvent +from zha.zigbee.cluster_handlers.const import ( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + CLUSTER_HANDLER_LEVEL, + CLUSTER_HANDLER_LEVEL_CHANGED, + CLUSTER_HANDLER_ON_OFF, +) +from zha.zigbee.cluster_handlers.general import ( + LevelChangeEvent, + LevelControlClusterHandler, + OnOffClusterHandler, +) + +if TYPE_CHECKING: + from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.device import Device + from zha.zigbee.endpoint import Endpoint class BaseValve(BaseEntity, ABC): @@ -104,3 +131,152 @@ async def async_set_valve_position(self, position: int) -> None: @abstractmethod async def async_stop_valve(self) -> None: """Stop the valve.""" + + +@register_entity(OnOff.cluster_id) +class Valve(PlatformEntity, BaseValve): + """Representation of a ZHA valve.""" + + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + _cluster_handler_match = ClusterHandlerMatch( + cluster_handlers=frozenset({CLUSTER_HANDLER_ON_OFF}), + optional_cluster_handlers=frozenset({CLUSTER_HANDLER_LEVEL}), + # Keep valve as opt-in via platform override. + feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, -2), + ) + + def __init__( + self, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> None: + """Initialize the valve.""" + super().__init__(cluster_handlers, endpoint, device, **kwargs) + self._on_off_cluster_handler: OnOffClusterHandler = cast( + OnOffClusterHandler, self.cluster_handlers[CLUSTER_HANDLER_ON_OFF] + ) + self._level_cluster_handler: LevelControlClusterHandler | None = cast( + LevelControlClusterHandler | None, + self.cluster_handlers.get(CLUSTER_HANDLER_LEVEL), + ) + + if self._level_cluster_handler is not None: + self._attr_supported_features |= ( + ValveEntityFeature.SET_POSITION | ValveEntityFeature.STOP + ) + + def on_add(self) -> None: + """Run when entity is added.""" + super().on_add() + self._on_remove_callbacks.append( + self._on_off_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + ) + + if self._level_cluster_handler is not None: + self._on_remove_callbacks.append( + self._level_cluster_handler.on_event( + CLUSTER_HANDLER_LEVEL_CHANGED, + self.handle_cluster_handler_set_level, + ) + ) + + @property + def reports_position(self) -> bool: + """Return if the valve reports position.""" + return self._level_cluster_handler is not None + + @property + def current_valve_position(self) -> int | None: + """Return current valve position.""" + if self._level_cluster_handler is None: + return None + return self._zcl_level_to_ha_position(self._level_cluster_handler.current_level) + + @property + def is_closed(self) -> bool | None: + """Return if the valve is closed.""" + if self.reports_position: + if (position := self.current_valve_position) is None: + return None + return position == 0 + + if self._on_off_cluster_handler.on_off is None: + return None + return not self._on_off_cluster_handler.on_off + + @property + def is_opening(self) -> bool | None: + """Return if the valve is opening.""" + return None + + @property + def is_closing(self) -> bool | None: + """Return if the valve is closing.""" + return None + + async def async_open_valve(self) -> None: + """Open the valve.""" + await self._on_off_cluster_handler.turn_on() + self.maybe_emit_state_changed_event() + + async def async_close_valve(self) -> None: + """Close the valve.""" + await self._on_off_cluster_handler.turn_off() + self.maybe_emit_state_changed_event() + + async def async_set_valve_position(self, position: int) -> None: + """Move the valve to a specific position.""" + if self._level_cluster_handler is None: + if position <= 0: + await self.async_close_valve() + else: + await self.async_open_valve() + return + + res = await self._level_cluster_handler.move_to_level_with_on_off( + self._ha_position_to_zcl_level(position), 1 + ) + if res[1] != Status.SUCCESS: + raise ZHAException(f"Failed to set valve position: {res[1]}") + + self.maybe_emit_state_changed_event() + + async def async_stop_valve(self) -> None: + """Stop the valve.""" + if self._level_cluster_handler is None: + return + + res = await self._level_cluster_handler.stop() + if res[1] != Status.SUCCESS: + raise ZHAException(f"Failed to stop valve: {res[1]}") + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle state update from cluster handler.""" + if event.attribute_name == OnOff.AttributeDefs.on_off.name: + self.maybe_emit_state_changed_event() + + def handle_cluster_handler_set_level(self, event: LevelChangeEvent) -> None: + """Handle state update from level cluster handler.""" + self.maybe_emit_state_changed_event() + + @staticmethod + def _ha_position_to_zcl_level(position: int) -> int: + """Convert the HA position to the ZCL level range.""" + return round(position * 255 / 100) + + @staticmethod + def _zcl_level_to_ha_position(level: int | None) -> int | None: + """Convert the ZCL level to the HA position range.""" + if level is None: + return None + level = max(0, min(255, level)) + return round(level * 100 / 255) From 8663724db9b9792a1d18e889be50c3349451a058 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 23 Feb 2026 13:18:25 -0500 Subject: [PATCH 3/3] Rename `LIGHT_OR_SWITCH_OR_SHADE` to `GENERIC_OPEN_CLOSE` --- tests/test_discover.py | 2 +- zha/application/platforms/__init__.py | 6 ++++-- zha/application/platforms/cover/__init__.py | 4 ++-- zha/application/platforms/light/__init__.py | 8 ++++---- zha/application/platforms/switch.py | 2 +- zha/application/platforms/valve/__init__.py | 2 +- 6 files changed, 13 insertions(+), 11 deletions(-) diff --git a/tests/test_discover.py b/tests/test_discover.py index ce564b8ab..bacd2e917 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -157,7 +157,7 @@ async def test_device_override_picks_highest_priority( """Test that a device override selects only the highest-priority match.""" # A Philips light matches both Light (priority 0) and HueLight (priority 1) in the - # LIGHT_OR_SWITCH_OR_SHADE feature group. With a SWITCH override, only one Switch + # GENERIC_OPEN_CLOSE feature group. With a SWITCH override, only one Switch # entity should be created, not duplicates from collecting all priority levels. zigpy_device = await zigpy_device_from_json( zha_gateway.application_controller, diff --git a/zha/application/platforms/__init__.py b/zha/application/platforms/__init__.py index fae77701d..eb6631dc9 100644 --- a/zha/application/platforms/__init__.py +++ b/zha/application/platforms/__init__.py @@ -44,8 +44,10 @@ class PlatformFeatureGroup(StrEnum): """Feature groups for platform entities.""" - # OnOff server clusters can be turned into lights, shades, or switches (fallback) - LIGHT_OR_SWITCH_OR_SHADE = "light_or_switch_or_shade" + # OnOff server clusters can be turned into lights, shades, switches (fallback). + # Valves are also allowed via overrides but have no device types so are not auto + # discovered. + GENERIC_OPEN_CLOSE = "generic_open_close" # OnOff client clusters can be turned into manufacturer-specific motion sensors or # fall back to generic binary sensors diff --git a/zha/application/platforms/cover/__init__.py b/zha/application/platforms/cover/__init__.py index 34ed18402..cbf12c083 100644 --- a/zha/application/platforms/cover/__init__.py +++ b/zha/application/platforms/cover/__init__.py @@ -721,7 +721,7 @@ class Shade(BaseCover): (512, zha.DeviceType.SHADE), # TODO: remove this Tuya hack } ), - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, 0), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, 0), ) def __init__( @@ -912,7 +912,7 @@ class KeenVent(Shade): _cluster_handler_match = ClusterHandlerMatch( cluster_handlers=frozenset({CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF}), manufacturers=frozenset({"Keen Home Inc"}), - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, 1), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, 1), ) async def async_open_cover(self, **kwargs: Any) -> None: # pylint: disable=unused-argument diff --git a/zha/application/platforms/light/__init__.py b/zha/application/platforms/light/__init__.py index dcf4393b4..0c3e2a9ec 100644 --- a/zha/application/platforms/light/__init__.py +++ b/zha/application/platforms/light/__init__.py @@ -758,7 +758,7 @@ class Light(BaseClusterHandlerLight, PlatformEntity): {CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_LEVEL} ), profile_device_types=LIGHT_PROFILE_DEVICE_TYPES, - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, 0), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, 0), ) def __init__( @@ -1050,7 +1050,7 @@ class HueLight(Light): manufacturers=frozenset({"Philips", "Signify Netherlands B.V."}), profile_device_types=LIGHT_PROFILE_DEVICE_TYPES, # We want this entity to be preferred over the base light - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, 1), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, 1), ) @@ -1076,7 +1076,7 @@ class ForceOnLight(Light): ), profile_device_types=LIGHT_PROFILE_DEVICE_TYPES, # We want this entity to be preferred over the base light - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, 1), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, 1), ) @@ -1095,7 +1095,7 @@ class MinTransitionLight(Light): manufacturers=DEFAULT_MIN_TRANSITION_MANUFACTURERS, profile_device_types=LIGHT_PROFILE_DEVICE_TYPES, # We want this entity to be preferred over the base light - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, 1), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, 1), ) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 2390b7481..5715a60c5 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -121,7 +121,7 @@ class Switch(PlatformEntity, BaseSwitch): _cluster_handler_match = ClusterHandlerMatch( cluster_handlers=frozenset({CLUSTER_HANDLER_ON_OFF}), # Switch entities have the lowest priority - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, -1), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, -1), ) def __init__( diff --git a/zha/application/platforms/valve/__init__.py b/zha/application/platforms/valve/__init__.py index 6fadffedc..5c60a84e7 100644 --- a/zha/application/platforms/valve/__init__.py +++ b/zha/application/platforms/valve/__init__.py @@ -143,7 +143,7 @@ class Valve(PlatformEntity, BaseValve): cluster_handlers=frozenset({CLUSTER_HANDLER_ON_OFF}), optional_cluster_handlers=frozenset({CLUSTER_HANDLER_LEVEL}), # Keep valve as opt-in via platform override. - feature_priority=(PlatformFeatureGroup.LIGHT_OR_SWITCH_OR_SHADE, -2), + feature_priority=(PlatformFeatureGroup.GENERIC_OPEN_CLOSE, -2), ) def __init__(