From 2125a4123d4c8727854d2511a350ce7b37b45e7c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Sat, 25 Oct 2025 23:33:20 +0300 Subject: [PATCH] Add zones support to Shelly Irrigation controller (#152382) --- homeassistant/components/shelly/switch.py | 6 +++ homeassistant/components/shelly/utils.py | 17 ++++++- homeassistant/components/shelly/valve.py | 54 ++++++++++++++++++-- tests/components/shelly/test_devices.py | 62 ++++++++++++++++++++++- 4 files changed, 133 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 681d80ec49e..c3c1619f241 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -165,6 +165,7 @@ RPC_SWITCHES = { "boolean_zone0": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -175,6 +176,7 @@ RPC_SWITCHES = { "boolean_zone1": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -185,6 +187,7 @@ RPC_SWITCHES = { "boolean_zone2": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -195,6 +198,7 @@ RPC_SWITCHES = { "boolean_zone3": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -205,6 +209,7 @@ RPC_SWITCHES = { "boolean_zone4": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -215,6 +220,7 @@ RPC_SWITCHES = { "boolean_zone5": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 36478a3dc62..5c1dac75d28 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -417,6 +417,11 @@ def get_rpc_sub_device_name( """Get name based on device and channel name.""" if key in device.config and key != "em:0": # workaround for Pro 3EM, we don't want to get name for em:0 + if (zone_id := get_irrigation_zone_id(device.config, key)) is not None: + # workaround for Irrigation controller, name stored in "service:0" + if zone_name := device.config["service:0"]["zones"][zone_id]["name"]: + return cast(str, zone_name) + if entity_name := device.config[key].get("name"): return cast(str, entity_name) @@ -787,6 +792,13 @@ async def get_rpc_scripts_event_types( return script_events +def get_irrigation_zone_id(config: dict[str, Any], key: str) -> int | None: + """Return the zone id if the component is an irrigation zone.""" + if key in config and (zone := get_rpc_role_by_key(config, key)).startswith("zone"): + return int(zone[4:]) + return None + + def get_rpc_device_info( device: RpcDevice, mac: str, @@ -823,7 +835,10 @@ def get_rpc_device_info( ) if ( - component not in (*All_LIGHT_TYPES, "cover", "em1", "switch") + ( + component not in (*All_LIGHT_TYPES, "cover", "em1", "switch") + and get_irrigation_zone_id(device.config, key) is None + ) or idx is None or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2 ): diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index ada262e7bbf..572cd5e08d4 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -17,7 +17,11 @@ from homeassistant.components.valve import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE +from .const import ( + MODEL_FRANKEVER_IRRIGATION_CONTROLLER, + MODEL_FRANKEVER_WATER_VALVE, + MODEL_NEO_WATER_VALVE, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -92,8 +96,8 @@ class RpcShellyWaterValve(RpcShellyBaseWaterValve): await self.coordinator.device.number_set(self._id, position) -class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve): - """Entity that controls a valve on RPC Shelly NEO Water Valve.""" +class RpcShellySimpleWaterValve(RpcShellyBaseWaterValve): + """Entity that controls a valve on RPC Shelly Open/Close Water Valve.""" _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE _attr_reports_position = False @@ -124,9 +128,51 @@ RPC_VALVES: dict[str, RpcValveDescription] = { key="boolean", sub_key="value", role="state", - entity_class=RpcShellyNeoWaterValve, + entity_class=RpcShellySimpleWaterValve, models={MODEL_NEO_WATER_VALVE}, ), + "boolean_zone0": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone0", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone1": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone1", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone2": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone2", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone3": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone3", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone4": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone4", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone5": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone5", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), } diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index bee8cd7c2c5..008002edbc5 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -12,7 +12,10 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import ( + DOMAIN, + MODEL_FRANKEVER_IRRIGATION_CONTROLLER, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -475,6 +478,63 @@ async def test_shelly_pro_3em_with_emeter_name( assert device_entry.name == "Test name Phase C" +async def test_shelly_fk_06x_with_zone_names( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly Irrigation controller FK-06X with zone names. + + We should get the main device and 6 subdevices, one subdevice per one zone. + """ + device_fixture = await async_load_json_object_fixture( + hass, "fk-06x_gen3_irrigation.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=3, model=MODEL_FRANKEVER_IRRIGATION_CONTROLLER) + + # Main device + entity_id = "sensor.test_name_average_temperature" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + # 3 zones with names, 3 with default names + zone_names = [ + "Zone Name 1", + "Zone Name 2", + "Zone Name 3", + "Zone 4", + "Zone 5", + "Zone 6", + ] + + for zone_name in zone_names: + entity_id = f"valve.{zone_name.lower().replace(' ', '_')}" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == zone_name + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_channel_with_name( hass: HomeAssistant,