diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 0fab86f7f4f..6b7a00db8e2 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -63,6 +63,7 @@ BLOCK_PLATFORMS: Final = [ Platform.SENSOR, Platform.SWITCH, Platform.UPDATE, + Platform.VALVE, ] BLOCK_SLEEPING_PLATFORMS: Final = [ Platform.BINARY_SENSOR, diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 330dd246c47..c1f9b799444 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -160,6 +160,14 @@ "push_update_failure": { "title": "Shelly device {device_name} push update failure", "description": "Home Assistant is not receiving push updates from the Shelly device {device_name} with IP address {ip_address}. Check the CoIoT configuration in the web panel of the device and your network configuration." + }, + "deprecated_valve_switch": { + "title": "The switch entity for Shelly Gas Valve is deprecated", + "description": "The switch entity for Shelly Gas Valve is deprecated. A valve entity {entity} is available and should be used going forward. For this new valve entity you need to use {service} service." + }, + "deprecated_valve_switch_entity": { + "title": "Deprecated switch entity for Shelly Gas Valve detected in {info}", + "description": "Your Shelly Gas Valve entity `{entity}` is being used in `{info}`. A valve entity is available and should be used going forward.\n\nPlease adjust `{info}` to fix this issue." } } } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 98811c2ff6f..e5d91943a55 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -7,12 +7,20 @@ from typing import Any, cast from aioshelly.block_device import Block from aioshelly.const import MODEL_2, MODEL_25, MODEL_GAS, RPC_GENERATIONS -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.automation import automations_with_entity +from homeassistant.components.script import scripts_with_entity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from .const import GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY +from .const import DOMAIN, GAS_VALVE_OPEN_STATES, MODEL_WALL_DISPLAY from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data from .entity import ( BlockEntityDescription, @@ -35,11 +43,13 @@ class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): """Class to describe a BLOCK switch.""" +# This entity description is deprecated and will be removed in Home Assistant 2024.7.0. GAS_VALVE_SWITCH = BlockSwitchDescription( key="valve|valve", name="Valve", available=lambda block: block.valve not in ("failure", "checking"), removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), + entity_registry_enabled_default=False, ) @@ -137,7 +147,10 @@ def async_setup_rpc_entry( class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): - """Entity that controls a Gas Valve on Block based Shelly devices.""" + """Entity that controls a Gas Valve on Block based Shelly devices. + + This class is deprecated and will be removed in Home Assistant 2024.7.0. + """ entity_description: BlockSwitchDescription @@ -167,14 +180,61 @@ class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Open valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.open_valve", + }, + ) self.control_result = await self.set_state(go="open") self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Close valve.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_valve_switch", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switche", + translation_placeholders={ + "entity": f"{VALVE_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "service": f"{VALVE_DOMAIN}.close_valve", + }, + ) self.control_result = await self.set_state(go="close") self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + await super().async_added_to_hass() + + entity_automations = automations_with_entity(self.hass, self.entity_id) + entity_scripts = scripts_with_entity(self.hass, self.entity_id) + for item in entity_automations + entity_scripts: + async_create_issue( + self.hass, + DOMAIN, + f"deprecated_valve_{self.entity_id}_{item}", + breaks_in_ha_version="2024.7.0", + is_fixable=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_valve_switch_entity", + translation_placeholders={ + "entity": f"{SWITCH_DOMAIN}.{cast(str, self.name).lower().replace(' ', '_')}", + "info": item, + }, + ) + @callback def _update_callback(self) -> None: """When device updates, clear control result that overrides state.""" diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py new file mode 100644 index 00000000000..7bc4a9a5329 --- /dev/null +++ b/homeassistant/components/shelly/valve.py @@ -0,0 +1,122 @@ +"""Valve for Shelly.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +from aioshelly.block_device import Block +from aioshelly.const import BLOCK_GENERATIONS, MODEL_GAS + +from homeassistant.components.valve import ( + ValveDeviceClass, + ValveEntity, + ValveEntityDescription, + ValveEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .coordinator import ShellyBlockCoordinator, get_entry_data +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + async_setup_block_attribute_entities, +) +from .utils import get_device_entry_gen + + +@dataclass(kw_only=True, frozen=True) +class BlockValveDescription(BlockEntityDescription, ValveEntityDescription): + """Class to describe a BLOCK valve.""" + + +GAS_VALVE = BlockValveDescription( + key="valve|valve", + name="Valve", + available=lambda block: block.valve not in ("failure", "checking"), + removal_condition=lambda _, block: block.valve in ("not_connected", "unknown"), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> 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) + + +@callback +def async_setup_block_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up valve for device.""" + coordinator = get_entry_data(hass)[config_entry.entry_id].block + assert coordinator and coordinator.device.blocks + + if coordinator.model == MODEL_GAS: + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE}, + BlockShellyValve, + ) + + +class BlockShellyValve(ShellyBlockAttributeEntity, ValveEntity): + """Entity that controls a valve on block based Shelly devices.""" + + entity_description: BlockValveDescription + _attr_device_class = ValveDeviceClass.GAS + _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockValveDescription, + ) -> None: + """Initialize block valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + self._attr_is_closed = bool(self.attribute_value == "closed") + + @property + def is_closing(self) -> bool: + """Return if the valve is closing.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "closing") + + return self.attribute_value == "closing" + + @property + def is_opening(self) -> bool: + """Return if the valve is opening.""" + if self.control_result: + return cast(bool, self.control_result["state"] == "opening") + + return self.attribute_value == "opening" + + async def async_open_valve(self, **kwargs: Any) -> None: + """Open valve.""" + self.control_result = await self.set_state(go="open") + self.async_write_ha_state() + + async def async_close_valve(self, **kwargs: Any) -> None: + """Close valve.""" + self.control_result = await self.set_state(go="close") + self.async_write_ha_state() + + @callback + def _update_callback(self) -> None: + """When device updates, clear control result that overrides state.""" + self.control_result = None + self._attr_is_closed = bool(self.attribute_value == "closed") + super()._update_callback() diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index e19416706e1..9a99116e66c 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -6,7 +6,10 @@ from aioshelly.const import MODEL_GAS from aioshelly.exceptions import DeviceConnectionError, InvalidAuthError, RpcCallError import pytest +from homeassistant.components import automation, script +from homeassistant.components.automation import automations_with_entity from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.script import scripts_with_entity from homeassistant.components.shelly.const import DOMAIN, MODEL_WALL_DISPLAY from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState @@ -21,6 +24,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component from . import init_integration, register_entity @@ -238,9 +243,14 @@ async def test_block_device_gas_valve( hass: HomeAssistant, mock_block_device, monkeypatch ) -> None: """Test block device Shelly Gas with Valve addon.""" + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) registry = er.async_get(hass) await init_integration(hass, 1, MODEL_GAS) - entity_id = "switch.test_name_valve" entry = registry.async_get(entity_id) assert entry @@ -316,3 +326,63 @@ async def test_wall_display_relay_mode( # the climate entity should be removed assert hass.states.get(entity_id) is None + + +async def test_create_issue_valve_switch( + hass: HomeAssistant, + mock_block_device, + entity_registry_enabled_by_default: None, + monkeypatch, +) -> None: + """Test we create an issue when an automation or script is using a deprecated entity.""" + monkeypatch.setitem(mock_block_device.status, "cloud", {"connected": False}) + entity_id = register_entity( + hass, + SWITCH_DOMAIN, + "test_name_valve", + "valve_0-valve", + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "alias": "test", + "trigger": {"platform": "state", "entity_id": entity_id}, + "action": {"service": "switch.turn_on", "entity_id": entity_id}, + } + }, + ) + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "test": { + "sequence": [ + { + "service": "switch.turn_on", + "data": {"entity_id": entity_id}, + }, + ], + } + } + }, + ) + + await init_integration(hass, 1, MODEL_GAS) + + assert automations_with_entity(hass, entity_id)[0] == "automation.test" + assert scripts_with_entity(hass, entity_id)[0] == "script.test" + issue_registry: ir.IssueRegistry = ir.async_get(hass) + + assert issue_registry.async_get_issue(DOMAIN, "deprecated_valve_switch") + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_automation.test" + ) + assert issue_registry.async_get_issue( + DOMAIN, "deprecated_valve_switch.test_name_valve_script.test" + ) + + assert len(issue_registry.issues) == 3 diff --git a/tests/components/shelly/test_valve.py b/tests/components/shelly/test_valve.py new file mode 100644 index 00000000000..0db79b63a9d --- /dev/null +++ b/tests/components/shelly/test_valve.py @@ -0,0 +1,72 @@ +"""Tests for Shelly valve platform.""" +from aioshelly.const import MODEL_GAS + +from homeassistant.components.valve import DOMAIN as VALVE_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_VALVE, + SERVICE_OPEN_VALVE, + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +GAS_VALVE_BLOCK_ID = 6 + + +async def test_block_device_gas_valve( + hass: HomeAssistant, mock_block_device, monkeypatch +) -> None: + """Test block device Shelly Gas with Valve addon.""" + registry = er.async_get(hass) + await init_integration(hass, 1, MODEL_GAS) + entity_id = "valve.test_name_valve" + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-valve_0-valve" + + assert hass.states.get(entity_id).state == STATE_CLOSED + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_OPEN_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPENING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "opened") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + await hass.services.async_call( + VALVE_DOMAIN, + SERVICE_CLOSE_VALVE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSING + + monkeypatch.setattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "valve", "closed") + mock_block_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED