Skip to content

Commit 404f95b

Browse files
authored
Add Shelly support for valve entities (home-assistant#153348)
1 parent 89cf784 commit 404f95b

File tree

4 files changed

+284
-4
lines changed

4 files changed

+284
-4
lines changed

homeassistant/components/shelly/const.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,3 +308,7 @@ class BLEScannerMode(StrEnum):
308308
MAX_SCRIPT_SIZE = 5120
309309

310310
All_LIGHT_TYPES = ("cct", "light", "rgb", "rgbw")
311+
312+
# Shelly-X specific models
313+
MODEL_NEO_WATER_VALVE = "NeoWaterValve"
314+
MODEL_FRANKEVER_WATER_VALVE = "WaterValve"

homeassistant/components/shelly/entity.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,9 @@ def async_setup_rpc_attribute_entities(
186186

187187
for key in key_instances:
188188
# Filter non-existing sensors
189+
if description.models and coordinator.model not in description.models:
190+
continue
191+
189192
if description.role and description.role != coordinator.device.config[
190193
key
191194
].get("role", "generic"):
@@ -316,6 +319,7 @@ class RpcEntityDescription(EntityDescription):
316319
options_fn: Callable[[dict], list[str]] | None = None
317320
entity_class: Callable | None = None
318321
role: str | None = None
322+
models: set[str] | None = None
319323

320324

321325
@dataclass(frozen=True)

homeassistant/components/shelly/valve.py

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,15 @@
1717
from homeassistant.core import HomeAssistant, callback
1818
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
1919

20-
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry
20+
from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE
21+
from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator
2122
from .entity import (
2223
BlockEntityDescription,
24+
RpcEntityDescription,
2325
ShellyBlockAttributeEntity,
26+
ShellyRpcAttributeEntity,
2427
async_setup_block_attribute_entities,
28+
async_setup_entry_rpc,
2529
)
2630
from .utils import async_remove_shelly_entity, get_device_entry_gen
2731

@@ -33,6 +37,11 @@ class BlockValveDescription(BlockEntityDescription, ValveEntityDescription):
3337
"""Class to describe a BLOCK valve."""
3438

3539

40+
@dataclass(kw_only=True, frozen=True)
41+
class RpcValveDescription(RpcEntityDescription, ValveEntityDescription):
42+
"""Class to describe a RPC virtual valve."""
43+
44+
3645
GAS_VALVE = BlockValveDescription(
3746
key="valve|valve",
3847
name="Valve",
@@ -41,14 +50,108 @@ class BlockValveDescription(BlockEntityDescription, ValveEntityDescription):
4150
)
4251

4352

53+
class RpcShellyBaseWaterValve(ShellyRpcAttributeEntity, ValveEntity):
54+
"""Base Entity for RPC Shelly Water Valves."""
55+
56+
entity_description: RpcValveDescription
57+
_attr_device_class = ValveDeviceClass.WATER
58+
_id: int
59+
60+
def __init__(
61+
self,
62+
coordinator: ShellyRpcCoordinator,
63+
key: str,
64+
attribute: str,
65+
description: RpcEntityDescription,
66+
) -> None:
67+
"""Initialize RPC water valve."""
68+
super().__init__(coordinator, key, attribute, description)
69+
self._attr_name = None # Main device entity
70+
71+
72+
class RpcShellyWaterValve(RpcShellyBaseWaterValve):
73+
"""Entity that controls a valve on RPC Shelly Water Valve."""
74+
75+
_attr_supported_features = (
76+
ValveEntityFeature.OPEN
77+
| ValveEntityFeature.CLOSE
78+
| ValveEntityFeature.SET_POSITION
79+
)
80+
_attr_reports_position = True
81+
82+
@property
83+
def current_valve_position(self) -> int:
84+
"""Return current position of valve."""
85+
return cast(int, self.attribute_value)
86+
87+
async def async_set_valve_position(self, position: int) -> None:
88+
"""Move the valve to a specific position."""
89+
await self.coordinator.device.number_set(self._id, position)
90+
91+
92+
class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve):
93+
"""Entity that controls a valve on RPC Shelly NEO Water Valve."""
94+
95+
_attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE
96+
_attr_reports_position = False
97+
98+
@property
99+
def is_closed(self) -> bool | None:
100+
"""Return if the valve is closed or not."""
101+
return not self.attribute_value
102+
103+
async def async_open_valve(self, **kwargs: Any) -> None:
104+
"""Open valve."""
105+
await self.coordinator.device.boolean_set(self._id, True)
106+
107+
async def async_close_valve(self, **kwargs: Any) -> None:
108+
"""Close valve."""
109+
await self.coordinator.device.boolean_set(self._id, False)
110+
111+
112+
RPC_VALVES: dict[str, RpcValveDescription] = {
113+
"water_valve": RpcValveDescription(
114+
key="number",
115+
sub_key="value",
116+
role="position",
117+
entity_class=RpcShellyWaterValve,
118+
models={MODEL_FRANKEVER_WATER_VALVE},
119+
),
120+
"neo_water_valve": RpcValveDescription(
121+
key="boolean",
122+
sub_key="value",
123+
role="state",
124+
entity_class=RpcShellyNeoWaterValve,
125+
models={MODEL_NEO_WATER_VALVE},
126+
),
127+
}
128+
129+
44130
async def async_setup_entry(
45131
hass: HomeAssistant,
46132
config_entry: ShellyConfigEntry,
47133
async_add_entities: AddConfigEntryEntitiesCallback,
48134
) -> None:
49135
"""Set up valves for device."""
50136
if get_device_entry_gen(config_entry) in BLOCK_GENERATIONS:
51-
async_setup_block_entry(hass, config_entry, async_add_entities)
137+
return async_setup_block_entry(hass, config_entry, async_add_entities)
138+
139+
return async_setup_rpc_entry(hass, config_entry, async_add_entities)
140+
141+
142+
@callback
143+
def async_setup_rpc_entry(
144+
hass: HomeAssistant,
145+
config_entry: ShellyConfigEntry,
146+
async_add_entities: AddConfigEntryEntitiesCallback,
147+
) -> None:
148+
"""Set up entities for RPC device."""
149+
coordinator = config_entry.runtime_data.rpc
150+
assert coordinator
151+
152+
async_setup_entry_rpc(
153+
hass, config_entry, async_add_entities, RPC_VALVES, RpcShellyWaterValve
154+
)
52155

53156

54157
@callback

tests/components/shelly/test_valve.py

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
11
"""Tests for Shelly valve platform."""
22

3+
from copy import deepcopy
34
from unittest.mock import Mock
45

56
from aioshelly.const import MODEL_GAS
67
import pytest
78

8-
from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN, ValveState
9-
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_CLOSE_VALVE, SERVICE_OPEN_VALVE
9+
from homeassistant.components.shelly.const import (
10+
MODEL_FRANKEVER_WATER_VALVE,
11+
MODEL_NEO_WATER_VALVE,
12+
)
13+
from homeassistant.components.valve import (
14+
ATTR_CURRENT_POSITION,
15+
ATTR_POSITION,
16+
DOMAIN as VALVE_DOMAIN,
17+
ValveState,
18+
)
19+
from homeassistant.const import (
20+
ATTR_ENTITY_ID,
21+
SERVICE_CLOSE_VALVE,
22+
SERVICE_OPEN_VALVE,
23+
SERVICE_SET_VALVE_POSITION,
24+
)
1025
from homeassistant.core import HomeAssistant
1126
from homeassistant.helpers.entity_registry import EntityRegistry
1227

@@ -64,3 +79,157 @@ async def test_block_device_gas_valve(
6479

6580
assert (state := hass.states.get(entity_id))
6681
assert state.state == ValveState.CLOSED
82+
83+
84+
async def test_rpc_water_valve(
85+
hass: HomeAssistant,
86+
entity_registry: EntityRegistry,
87+
mock_rpc_device: Mock,
88+
monkeypatch: pytest.MonkeyPatch,
89+
) -> None:
90+
"""Test RPC device Shelly Water Valve."""
91+
config = deepcopy(mock_rpc_device.config)
92+
config["number:200"] = {
93+
"name": "Position",
94+
"min": 0,
95+
"max": 100,
96+
"meta": {"ui": {"step": 10, "view": "slider", "unit": "%"}},
97+
"role": "position",
98+
}
99+
monkeypatch.setattr(mock_rpc_device, "config", config)
100+
101+
status = deepcopy(mock_rpc_device.status)
102+
status["number:200"] = {"value": 0}
103+
monkeypatch.setattr(mock_rpc_device, "status", status)
104+
105+
await init_integration(hass, 3, model=MODEL_FRANKEVER_WATER_VALVE)
106+
entity_id = "valve.test_name"
107+
108+
assert (entry := entity_registry.async_get(entity_id))
109+
assert entry.unique_id == "123456789ABC-number:200-water_valve"
110+
111+
assert (state := hass.states.get(entity_id))
112+
assert state.state == ValveState.CLOSED
113+
114+
# Open valve
115+
await hass.services.async_call(
116+
VALVE_DOMAIN,
117+
SERVICE_OPEN_VALVE,
118+
{ATTR_ENTITY_ID: entity_id},
119+
blocking=True,
120+
)
121+
122+
mock_rpc_device.number_set.assert_called_once_with(200, 100)
123+
124+
status["number:200"] = {"value": 100}
125+
monkeypatch.setattr(mock_rpc_device, "status", status)
126+
mock_rpc_device.mock_update()
127+
await hass.async_block_till_done()
128+
129+
assert (state := hass.states.get(entity_id))
130+
assert state.state == ValveState.OPEN
131+
132+
# Close valve
133+
mock_rpc_device.number_set.reset_mock()
134+
await hass.services.async_call(
135+
VALVE_DOMAIN,
136+
SERVICE_CLOSE_VALVE,
137+
{ATTR_ENTITY_ID: entity_id},
138+
blocking=True,
139+
)
140+
141+
mock_rpc_device.number_set.assert_called_once_with(200, 0)
142+
143+
status["number:200"] = {"value": 0}
144+
monkeypatch.setattr(mock_rpc_device, "status", status)
145+
mock_rpc_device.mock_update()
146+
await hass.async_block_till_done()
147+
148+
assert (state := hass.states.get(entity_id))
149+
assert state.state == ValveState.CLOSED
150+
151+
# Set valve position to 50%
152+
mock_rpc_device.number_set.reset_mock()
153+
await hass.services.async_call(
154+
VALVE_DOMAIN,
155+
SERVICE_SET_VALVE_POSITION,
156+
{ATTR_ENTITY_ID: entity_id, ATTR_POSITION: 50},
157+
blocking=True,
158+
)
159+
160+
mock_rpc_device.number_set.assert_called_once_with(200, 50)
161+
162+
status["number:200"] = {"value": 50}
163+
monkeypatch.setattr(mock_rpc_device, "status", status)
164+
mock_rpc_device.mock_update()
165+
await hass.async_block_till_done()
166+
167+
assert (state := hass.states.get(entity_id))
168+
assert state.state == ValveState.OPEN
169+
assert state.attributes.get(ATTR_CURRENT_POSITION) == 50
170+
171+
172+
async def test_rpc_neo_water_valve(
173+
hass: HomeAssistant,
174+
entity_registry: EntityRegistry,
175+
mock_rpc_device: Mock,
176+
monkeypatch: pytest.MonkeyPatch,
177+
) -> None:
178+
"""Test RPC device Shelly NEO Water Valve."""
179+
config = deepcopy(mock_rpc_device.config)
180+
config["boolean:200"] = {
181+
"name": "State",
182+
"meta": {"ui": {"view": "toggle"}},
183+
"role": "state",
184+
}
185+
monkeypatch.setattr(mock_rpc_device, "config", config)
186+
187+
status = deepcopy(mock_rpc_device.status)
188+
status["boolean:200"] = {"value": False}
189+
monkeypatch.setattr(mock_rpc_device, "status", status)
190+
191+
await init_integration(hass, 3, model=MODEL_NEO_WATER_VALVE)
192+
entity_id = "valve.test_name"
193+
194+
assert (entry := entity_registry.async_get(entity_id))
195+
assert entry.unique_id == "123456789ABC-boolean:200-neo_water_valve"
196+
197+
assert (state := hass.states.get(entity_id))
198+
assert state.state == ValveState.CLOSED
199+
200+
# Open valve
201+
await hass.services.async_call(
202+
VALVE_DOMAIN,
203+
SERVICE_OPEN_VALVE,
204+
{ATTR_ENTITY_ID: entity_id},
205+
blocking=True,
206+
)
207+
208+
mock_rpc_device.boolean_set.assert_called_once_with(200, True)
209+
210+
status["boolean:200"] = {"value": True}
211+
monkeypatch.setattr(mock_rpc_device, "status", status)
212+
mock_rpc_device.mock_update()
213+
await hass.async_block_till_done()
214+
215+
assert (state := hass.states.get(entity_id))
216+
assert state.state == ValveState.OPEN
217+
218+
# Close valve
219+
mock_rpc_device.boolean_set.reset_mock()
220+
await hass.services.async_call(
221+
VALVE_DOMAIN,
222+
SERVICE_CLOSE_VALVE,
223+
{ATTR_ENTITY_ID: entity_id},
224+
blocking=True,
225+
)
226+
227+
mock_rpc_device.boolean_set.assert_called_once_with(200, False)
228+
229+
status["boolean:200"] = {"value": False}
230+
monkeypatch.setattr(mock_rpc_device, "status", status)
231+
mock_rpc_device.mock_update()
232+
await hass.async_block_till_done()
233+
234+
assert (state := hass.states.get(entity_id))
235+
assert state.state == ValveState.CLOSED

0 commit comments

Comments
 (0)