diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index cc82f0ad700..33b4caa5034 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -179,3 +179,5 @@ MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" + +GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index cd9980921c8..abcca888005 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -312,6 +312,24 @@ SENSORS: Final = { value=lambda value: value, extra_state_attributes=lambda block: {"self_test": block.selfTest}, ), + ("valve", "valve"): BlockSensorDescription( + key="valve|valve", + name="Valve status", + translation_key="valve_status", + icon="mdi:valve", + device_class=SensorDeviceClass.ENUM, + options=[ + "checking", + "closed", + "closing", + "failure", + "opened", + "opening", + "unknown", + ], + entity_category=EntityCategory.DIAGNOSTIC, + removal_condition=lambda _, block: block.valve == "not_connected", + ), } REST_SENSORS: Final = { diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 6ff48f5b85b..043ff419742 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -116,6 +116,17 @@ } } } + }, + "valve_status": { + "state": { + "checking": "Checking", + "closed": "Closed", + "closing": "Closing", + "failure": "Failure", + "opened": "Opened", + "opening": "Opening", + "unknown": "[%key:component::shelly::entity::sensor::operation::state::unknown%]" + } } } }, diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 3f5186a2017..395b386993a 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -1,17 +1,25 @@ """Switch for Shelly.""" from __future__ import annotations +from dataclasses import dataclass from typing import Any, cast from aioshelly.block_device import Block -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from .const import GAS_VALVE_OPEN_STATES from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator, get_entry_data -from .entity import ShellyBlockEntity, ShellyRpcEntity +from .entity import ( + BlockEntityDescription, + ShellyBlockAttributeEntity, + ShellyBlockEntity, + ShellyRpcEntity, + async_setup_block_attribute_entities, +) from .utils import ( async_remove_shelly_entity, get_device_entry_gen, @@ -21,6 +29,19 @@ from .utils import ( ) +@dataclass +class BlockSwitchDescription(BlockEntityDescription, SwitchEntityDescription): + """Class to describe a BLOCK switch.""" + + +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"), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -43,6 +64,17 @@ def async_setup_block_entry( coordinator = get_entry_data(hass)[config_entry.entry_id].block assert coordinator + # Add Shelly Gas Valve as a switch + if coordinator.model == "SHGS-1": + async_setup_block_attribute_entities( + hass, + async_add_entities, + coordinator, + {("valve", "valve"): GAS_VALVE_SWITCH}, + BlockValveSwitch, + ) + return + # In roller mode the relay blocks exist but do not contain required info if ( coordinator.model in ["SHSW-21", "SHSW-25"] @@ -94,6 +126,53 @@ def async_setup_rpc_entry( async_add_entities(RpcRelaySwitch(coordinator, id_) for id_ in switch_ids) +class BlockValveSwitch(ShellyBlockAttributeEntity, SwitchEntity): + """Entity that controls a Gas Valve on Block based Shelly devices.""" + + entity_description: BlockSwitchDescription + + def __init__( + self, + coordinator: ShellyBlockCoordinator, + block: Block, + attribute: str, + description: BlockSwitchDescription, + ) -> None: + """Initialize valve.""" + super().__init__(coordinator, block, attribute, description) + self.control_result: dict[str, Any] | None = None + + @property + def is_on(self) -> bool: + """If valve is open.""" + if self.control_result: + return self.control_result["state"] in GAS_VALVE_OPEN_STATES + + return self.attribute_value in GAS_VALVE_OPEN_STATES + + @property + def icon(self) -> str: + """Return the icon.""" + return "mdi:valve-open" if self.is_on else "mdi:valve-closed" + + async def async_turn_on(self, **kwargs: Any) -> None: + """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.""" + 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 + + super()._update_callback() + + class BlockRelaySwitch(ShellyBlockEntity, SwitchEntity): """Entity that controls a relay on Block based Shelly devices.""" diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index de12adefaf1..797673265a6 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -131,6 +131,16 @@ MOCK_BLOCKS = [ description="emeter_0", type="emeter", ), + Mock( + sensor_ids={"valve": "closed"}, + valve="closed", + channel="0", + description="valve_0", + type="valve", + set_state=AsyncMock( + side_effect=lambda go: {"state": "opening" if go == "open" else "closing"} + ), + ), ] MOCK_CONFIG = { diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c806cb5e742..08ec548d3f0 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -32,6 +32,7 @@ from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data SENSOR_BLOCK_ID = 3 DEVICE_BLOCK_ID = 4 EMETER_BLOCK_ID = 5 +GAS_VALVE_BLOCK_ID = 6 ENTITY_ID = f"{CLIMATE_DOMAIN}.test_name" @@ -47,6 +48,7 @@ async def test_climate_hvac_mode( ) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") # Make device online @@ -103,6 +105,7 @@ async def test_climate_set_temperature( """Test climate set temperature service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") await init_integration(hass, 1, sleep_period=1000) # Make device online @@ -144,6 +147,7 @@ async def test_climate_set_preset_mode( ) -> None: """Test climate set preset mode service.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", None) await init_integration(hass, 1, sleep_period=1000, model="SHTRV-01") @@ -198,6 +202,7 @@ async def test_block_restored_climate( ) -> None: """Test block restored climate.""" monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) @@ -261,6 +266,7 @@ async def test_block_restored_climate_us_customery( """Test block restored climate with US CUSTOMATY unit system.""" hass.config.units = US_CUSTOMARY_SYSTEM monkeypatch.delattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "targetTemp") + monkeypatch.delattr(mock_block_device.blocks[GAS_VALVE_BLOCK_ID], "targetTemp") monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.delattr(mock_block_device.blocks[EMETER_BLOCK_ID], "targetTemp") entry = await init_integration(hass, 1, sleep_period=1000, skip_setup=True) diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index a93d752f9e2..a53c5dc185b 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -9,6 +9,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ICON, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -16,10 +17,12 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from . import init_integration RELAY_BLOCK_ID = 0 +GAS_VALVE_BLOCK_ID = 6 async def test_block_device_services(hass: HomeAssistant, mock_block_device) -> None: @@ -226,3 +229,51 @@ async def test_rpc_auth_error( assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +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, "SHGS-1") + entity_id = "switch.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_OFF # valve is closed + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_ON # valve is open + assert state.attributes.get(ATTR_ICON) == "mdi:valve-open" + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF # valve is closed + assert state.attributes.get(ATTR_ICON) == "mdi:valve-closed" + + 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_ON # valve is open + assert state.attributes.get(ATTR_ICON) == "mdi:valve-open"