Add Shelly support for valve entities (#153348)

This commit is contained in:
Shay Levy
2025-10-03 15:04:35 +03:00
committed by GitHub
parent 89cf784022
commit 404f95b442
4 changed files with 284 additions and 4 deletions
+4
View File
@@ -308,3 +308,7 @@ DEVICE_UNIT_MAP = {
MAX_SCRIPT_SIZE = 5120
All_LIGHT_TYPES = ("cct", "light", "rgb", "rgbw")
# Shelly-X specific models
MODEL_NEO_WATER_VALVE = "NeoWaterValve"
MODEL_FRANKEVER_WATER_VALVE = "WaterValve"
@@ -186,6 +186,9 @@ def async_setup_rpc_attribute_entities(
for key in key_instances:
# Filter non-existing sensors
if description.models and coordinator.model not in description.models:
continue
if description.role and description.role != coordinator.device.config[
key
].get("role", "generic"):
@@ -316,6 +319,7 @@ class RpcEntityDescription(EntityDescription):
options_fn: Callable[[dict], list[str]] | None = None
entity_class: Callable | None = None
role: str | None = None
models: set[str] | None = None
@dataclass(frozen=True)
+105 -2
View File
@@ -17,11 +17,15 @@ from homeassistant.components.valve import (
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry
from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
from .entity import (
BlockEntityDescription,
RpcEntityDescription,
ShellyBlockAttributeEntity,
ShellyRpcAttributeEntity,
async_setup_block_attribute_entities,
async_setup_entry_rpc,
)
from .utils import async_remove_shelly_entity, get_device_entry_gen
@@ -33,6 +37,11 @@ class BlockValveDescription(BlockEntityDescription, ValveEntityDescription):
"""Class to describe a BLOCK valve."""
@dataclass(kw_only=True, frozen=True)
class RpcValveDescription(RpcEntityDescription, ValveEntityDescription):
"""Class to describe a RPC virtual valve."""
GAS_VALVE = BlockValveDescription(
key="valve|valve",
name="Valve",
@@ -41,6 +50,83 @@ GAS_VALVE = BlockValveDescription(
)
class RpcShellyBaseWaterValve(ShellyRpcAttributeEntity, ValveEntity):
"""Base Entity for RPC Shelly Water Valves."""
entity_description: RpcValveDescription
_attr_device_class = ValveDeviceClass.WATER
_id: int
def __init__(
self,
coordinator: ShellyRpcCoordinator,
key: str,
attribute: str,
description: RpcEntityDescription,
) -> None:
"""Initialize RPC water valve."""
super().__init__(coordinator, key, attribute, description)
self._attr_name = None # Main device entity
class RpcShellyWaterValve(RpcShellyBaseWaterValve):
"""Entity that controls a valve on RPC Shelly Water Valve."""
_attr_supported_features = (
ValveEntityFeature.OPEN
| ValveEntityFeature.CLOSE
| ValveEntityFeature.SET_POSITION
)
_attr_reports_position = True
@property
def current_valve_position(self) -> int:
"""Return current position of valve."""
return cast(int, self.attribute_value)
async def async_set_valve_position(self, position: int) -> None:
"""Move the valve to a specific position."""
await self.coordinator.device.number_set(self._id, position)
class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve):
"""Entity that controls a valve on RPC Shelly NEO Water Valve."""
_attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
_attr_reports_position = False
@property
def is_closed(self) -> bool | None:
"""Return if the valve is closed or not."""
return not self.attribute_value
async def async_open_valve(self, **kwargs: Any) -> None:
"""Open valve."""
await self.coordinator.device.boolean_set(self._id, True)
async def async_close_valve(self, **kwargs: Any) -> None:
"""Close valve."""
await self.coordinator.device.boolean_set(self._id, False)
RPC_VALVES: dict[str, RpcValveDescription] = {
"water_valve": RpcValveDescription(
key="number",
sub_key="value",
role="position",
entity_class=RpcShellyWaterValve,
models={MODEL_FRANKEVER_WATER_VALVE},
),
"neo_water_valve": RpcValveDescription(
key="boolean",
sub_key="value",
role="state",
entity_class=RpcShellyNeoWaterValve,
models={MODEL_NEO_WATER_VALVE},
),
}
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
@@ -48,7 +134,24 @@ async def async_setup_entry(
) -> None:
"""Set up valves for device."""
if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS:
async_setup_block_entry(hass, config_entry, async_add_entities)
return async_setup_block_entry(hass, config_entry, async_add_entities)
return async_setup_rpc_entry(hass, config_entry, async_add_entities)
@callback
def async_setup_rpc_entry(
hass: HomeAssistant,
config_entry: ShellyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up entities for RPC device."""
coordinator = config_entry.runtime_data.rpc
assert coordinator
async_setup_entry_rpc(
hass, config_entry, async_add_entities, RPC_VALVES, RpcShellyWaterValve
)
@callback
+171 -2
View File
@@ -1,12 +1,27 @@
"""Tests for Shelly valve platform."""
from copy import deepcopy
from unittest.mock import Mock
from aioshelly.const import MODEL_GAS
import pytest
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE
from homeassistant.components.shelly.const import (
MODEL_FRANKEVER_WATER_VALVE,
MODEL_NEO_WATER_VALVE,
)
from homeassistant.components.valve import (
ATTR_CURRENT_POSITION,
ATTR_POSITION,
DOMAIN as VALVE_DOMAIN,
ValveState,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_CLOSE_VALVE,
SERVICE_OPEN_VALVE,
SERVICE_SET_VALVE_POSITION,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
@@ -64,3 +79,157 @@ async def test_block_device_gas_valve(
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.CLOSED
async def test_rpc_water_valve(
hass: HomeAssistant,
entity_registry: EntityRegistry,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test RPC device Shelly Water Valve."""
config = deepcopy(mock_rpc_device.config)
config["number:200"] = {
"name": "Position",
"min": 0,
"max": 100,
"meta": {"ui": {"step": 10, "view": "slider", "unit": "%"}},
"role": "position",
}
monkeypatch.setattr(mock_rpc_device, "config", config)
status = deepcopy(mock_rpc_device.status)
status["number:200"] = {"value": 0}
monkeypatch.setattr(mock_rpc_device, "status", status)
await init_integration(hass, 3, model=MODEL_FRANKEVER_WATER_VALVE)
entity_id = "valve.test_name"
assert (entry := entity_registry.async_get(entity_id))
assert entry.unique_id == "123456789ABC-number:200-water_valve"
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.CLOSED
# Open valve
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_OPEN_VALVE,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_rpc_device.number_set.assert_called_once_with(200, 100)
status["number:200"] = {"value": 100}
monkeypatch.setattr(mock_rpc_device, "status", status)
mock_rpc_device.mock_update()
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.OPEN
# Close valve
mock_rpc_device.number_set.reset_mock()
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_rpc_device.number_set.assert_called_once_with(200, 0)
status["number:200"] = {"value": 0}
monkeypatch.setattr(mock_rpc_device, "status", status)
mock_rpc_device.mock_update()
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.CLOSED
# Set valve position to 50%
mock_rpc_device.number_set.reset_mock()
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_SET_VALVE_POSITION,
{ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50},
blocking=True,
)
mock_rpc_device.number_set.assert_called_once_with(200, 50)
status["number:200"] = {"value": 50}
monkeypatch.setattr(mock_rpc_device, "status", status)
mock_rpc_device.mock_update()
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.OPEN
assert state.attributes.get(ATTR_CURRENT_POSITION) == 50
async def test_rpc_neo_water_valve(
hass: HomeAssistant,
entity_registry: EntityRegistry,
mock_rpc_device: Mock,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Test RPC device Shelly NEO Water Valve."""
config = deepcopy(mock_rpc_device.config)
config["boolean:200"] = {
"name": "State",
"meta": {"ui": {"view": "toggle"}},
"role": "state",
}
monkeypatch.setattr(mock_rpc_device, "config", config)
status = deepcopy(mock_rpc_device.status)
status["boolean:200"] = {"value": False}
monkeypatch.setattr(mock_rpc_device, "status", status)
await init_integration(hass, 3, model=MODEL_NEO_WATER_VALVE)
entity_id = "valve.test_name"
assert (entry := entity_registry.async_get(entity_id))
assert entry.unique_id == "123456789ABC-boolean:200-neo_water_valve"
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.CLOSED
# Open valve
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_OPEN_VALVE,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_rpc_device.boolean_set.assert_called_once_with(200, True)
status["boolean:200"] = {"value": True}
monkeypatch.setattr(mock_rpc_device, "status", status)
mock_rpc_device.mock_update()
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.OPEN
# Close valve
mock_rpc_device.boolean_set.reset_mock()
await hass.services.async_call(
VALVE_DOMAIN,
SERVICE_CLOSE_VALVE,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
mock_rpc_device.boolean_set.assert_called_once_with(200, False)
status["boolean:200"] = {"value": False}
monkeypatch.setattr(mock_rpc_device, "status", status)
mock_rpc_device.mock_update()
await hass.async_block_till_done()
assert (state := hass.states.get(entity_id))
assert state.state == ValveState.CLOSED